Files
lockbitchat 9e63cf65a4 v6.8.0: light theme
The palette lived as ~620 hex literals in inline styles plus a few hundred more in
the stylesheets, so there was no single thing to change. It is now 113 custom
properties in src/styles/theme.css, in two blocks.

src/scripts/theme-boot.js decides the theme before first paint — blocking, in <head>,
above the stylesheet, because a deferred script paints dark first and corrects itself.
It stores the mode ('system' | 'light' | 'dark'), never the colour it resolved to, and
stamps data-theme so an explicit choice can beat the media query. The switcher in the
header is a view onto it.

A filled accent stays the brand colour in both themes — the ink on it is near-black
either way — while an accent used as text darkens to clear 4.5:1 on white. A colour
reaches a fill by four routes (a style property, a constant, a helper argument, an SVG
source string), and tests/theme-switching.test.mjs covers all four.

The dark theme is unchanged: every colour declaration the previous build produced comes
out of this one identically once the properties are resolved.

Also: the roadmap drops its status chips on mobile, and Roadmap.jsx no longer splits a
colour with parseInt at runtime, which a var() reference cannot survive.
2026-09-04 17:38:41 -04:00

431 lines
21 KiB
HTML

<!DOCTYPE html>
<!-- Generated by scripts/build-docs.js from doc/*.md. Edits here are overwritten;
change the Markdown instead. -->
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests;">
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
<title>Voice and video calls - SecureBit.chat</title>
<meta name="description" content="Calls run over the same peer connection as the chat. Media is added to the existing connection and renegotiated onto it, and the call SDP is exchanged…">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
<link rel="canonical" href="https://securebit.chat/docs/calls/">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
<meta property="og:site_name" content="SecureBit.chat">
<meta property="og:title" content="Voice and video calls - SecureBit.chat">
<meta property="og:description" content="Calls run over the same peer connection as the chat. Media is added to the existing connection and renegotiated onto it, and the call SDP is exchanged…">
<meta property="og:url" content="https://securebit.chat/docs/calls/">
<meta property="og:type" content="article">
<meta property="og:locale" content="en_US">
<meta property="og:image" content="https://securebit.chat/assets/social-card.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Voice and video calls - SecureBit.chat">
<meta name="twitter:description" content="Calls run over the same peer connection as the chat. Media is added to the existing connection and renegotiated onto it, and the call SDP is exchanged…">
<meta name="twitter:image" content="https://securebit.chat/assets/social-card.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Voice and video calls",
"description": "Calls run over the same peer connection as the chat. Media is added to the existing connection and renegotiated onto it, and the call SDP is exchanged…",
"url": "https://securebit.chat/docs/calls/",
"inLanguage": "en",
"isPartOf": {
"@type": "WebSite",
"@id": "https://securebit.chat/#website"
},
"publisher": {
"@id": "https://securebit.chat/#organization"
}
}
</script>
<style>
/* These pages carry no script — that is the point of them — so the theme can only
come from the media query. There is no toggle here and no stored preference: a
reference page follows the reader's system and nothing else. The values are the
same two palettes as src/styles/theme.css, restated because these pages do not
load the app's stylesheet and are not going to start for eleven declarations. */
:root {
color-scheme: dark;
--d-ink: 255, 255, 255;
--d-bg: #0f0f11;
--d-bg-deep: #0b0b0e;
--d-code-bg: #17171c;
--d-text: #d6d6dc;
--d-heading: #f4f4f6;
--d-strong: #e8e8eb;
--d-body: #a9a9b3;
--d-pre: #c9c9d1;
--d-muted: #8a8a92;
--d-faint: #6b6b73;
--d-accent: #f0892a;
--d-accent-rgb: 240, 137, 42;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--d-ink: 0, 0, 0;
--d-bg: #fbfbfc;
--d-bg-deep: #f2f3f5;
--d-code-bg: #f2f3f5;
--d-text: #26262c;
--d-heading: #0e0e12;
--d-strong: #1b1b20;
--d-body: #43434c;
--d-pre: #2f3340;
--d-muted: #63636c;
--d-faint: #7c7c85;
--d-accent: #b05c08;
--d-accent-rgb: 176, 92, 8;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--d-bg);
color: var(--d-text);
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.68;
}
.wrap { max-width: 46rem; margin: 0 auto; padding: 28px 24px 90px; }
.top {
display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: baseline;
padding-bottom: 16px; margin-bottom: 40px;
border-bottom: 1px solid rgba(var(--d-ink),.08);
font-size: 13.5px;
}
.top a { color: var(--d-muted); text-decoration: none; }
.top a:hover, .top a:focus-visible { color: var(--d-accent); }
.top .brand { color: var(--d-accent); font-weight: 700; letter-spacing: .04em; }
.top .here { color: var(--d-text); margin-inline-start: auto; }
h1 { font-size: clamp(28px, 5vw, 36px); font-weight: 800; letter-spacing: -1px; line-height: 1.14; color: var(--d-heading); margin: 0 0 24px; }
h2 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; color: var(--d-heading); margin: 46px 0 12px; padding-top: 14px; border-top: 1px solid rgba(var(--d-ink),.07); }
h3 { font-size: 17.5px; font-weight: 700; color: var(--d-strong); margin: 30px 0 8px; }
h4 { font-size: 15.5px; font-weight: 700; color: var(--d-strong); margin: 22px 0 6px; }
p, li { color: var(--d-body); }
p { margin: 0 0 16px; }
ul, ol { padding-inline-start: 22px; margin: 0 0 16px; }
li { margin: 5px 0; }
a { color: var(--d-accent); text-underline-offset: 2px; }
strong { color: var(--d-strong); }
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .88em; background: var(--d-code-bg); border: 1px solid rgba(var(--d-ink),.07); border-radius: 4px; padding: 1px 5px; color: var(--d-strong); }
pre { background: var(--d-bg-deep); border: 1px solid rgba(var(--d-ink),.08); border-radius: 8px; padding: 14px 16px; overflow-x: auto; margin: 0 0 18px; }
pre code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.62; color: var(--d-pre); }
.tablewrap { overflow-x: auto; margin: 0 0 20px; }
table { border-collapse: collapse; width: 100%; font-size: 14.5px; min-width: 30rem; }
th { text-align: start; color: var(--d-muted); font-weight: 600; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; padding: 0 14px 8px 0; border-bottom: 1px solid rgba(var(--d-ink),.12); }
td { padding: 9px 14px 9px 0; border-bottom: 1px solid rgba(var(--d-ink),.06); vertical-align: top; color: var(--d-body); }
td:first-child, th:first-child { padding-inline-start: 0; }
blockquote { margin: 0 0 18px; padding: 2px 0 2px 16px; border-inline-start: 3px solid rgba(var(--d-accent-rgb),.4); color: var(--d-muted); }
hr { border: 0; border-top: 1px solid rgba(var(--d-ink),.08); margin: 34px 0; }
img { max-width: 100%; height: auto; }
a:focus-visible { outline: 2px solid var(--d-accent); outline-offset: 2px; border-radius: 2px; }
.more { margin-top: 64px; padding-top: 22px; border-top: 1px solid rgba(var(--d-ink),.08); }
.more h2 { font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--d-faint); border: 0; margin: 0 0 12px; padding: 0; font-weight: 700; }
.more ul { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 4px 24px; }
.more li { margin: 0; padding: 7px 0; border-bottom: 1px solid rgba(var(--d-ink),.05); font-size: 14.5px; }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
</style>
</head>
<body>
<div class="wrap">
<nav class="top">
<a class="brand" href="/">SecureBit.chat</a>
<a href="/docs/">Documentation</a>
<a href="https://github.com/SecureBitChat/securebit-chat" rel="noopener">GitHub</a>
<span class="here">Voice and video calls</span>
</nav>
<h1>Voice and video calls</h1>
<p>Calls run over the same peer connection as the chat. Media is added to the
existing connection and renegotiated onto it, and the call SDP is exchanged over
the encrypted data channel rather than through any signalling service. Media
therefore inherits the session&#39;s verification: the DTLS-SRTP fingerprints
negotiated for the media were themselves carried over an authenticated channel.</p>
<p>All tunable values live in <code>src/network/webrtc/config.js</code>. This document explains
where they come from, because the numbers are otherwise indistinguishable from
arbitrary choices.</p>
<h2 id="where-the-settings-attach">Where the settings attach</h2>
<p>A single <code>RTCRtpSender</code> cannot express codec ordering or fmtp parameters, so
configuring a sender is spread across three WebRTC surfaces, each at the point in
the lifecycle where it works:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Concern</th>
<th>Surface</th>
<th>When</th>
<th>Implementation</th>
</tr>
</thead>
<tbody><tr>
<td>Codec ordering (RED before Opus)</td>
<td><code>transceiver.setCodecPreferences</code></td>
<td>before creating the offer or answer</td>
<td><code>applyAudioCodecPreferences</code> in <code>audio.js</code></td>
</tr>
<tr>
<td>Opus FEC, DTX, bitrate</td>
<td>SDP <code>a=fmtp</code> rewriting</td>
<td>after create, before <code>setLocalDescription</code></td>
<td><code>applyOpusSettings</code> in <code>sdp.js</code></td>
</tr>
<tr>
<td>Priority and maximum bitrate</td>
<td><code>sender.setParameters</code></td>
<td>after <code>setLocalDescription</code></td>
<td><code>configureAudioSender</code> in <code>audio.js</code></td>
</tr>
</tbody></table></div>
<p>Both peers apply the same rewriting, so the negotiated session carries the
parameters regardless of who called.</p>
<h2 id="audio">Audio</h2>
<p>Speech has to stay intelligible on a bad link, and that goal drives every value
below.</p>
<h3 id="opus-parameters">Opus parameters</h3>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td><code>minptime</code></td>
<td>10</td>
<td>Smaller packetisation interval, lower latency (RFC 7587, section 7)</td>
</tr>
<tr>
<td><code>useinbandfec</code></td>
<td>1</td>
<td>In-band forward error correction reconstructs a lost packet from the next one. This is the main lever for staying intelligible at 15 to 20 percent loss (RFC 6716, section 2.1.7)</td>
</tr>
<tr>
<td><code>usedtx</code></td>
<td>1</td>
<td>Discontinuous transmission stops sending during silence, leaving the transport free for video and FEC (RFC 7587, section 3.1.3)</td>
</tr>
<tr>
<td><code>stereo</code></td>
<td>0</td>
<td>Mono halves the bitrate with no loss for speech</td>
</tr>
<tr>
<td><code>maxaveragebitrate</code></td>
<td>32000</td>
<td>Comfortable wideband speech</td>
</tr>
<tr>
<td><code>cbr</code></td>
<td>0</td>
<td>Variable bitrate spends bits only when needed</td>
</tr>
</tbody></table></div>
<h3 id="redundancy">Redundancy</h3>
<p>RED (RFC 2198) carries the previous frame&#39;s payload alongside each packet, so
isolated losses recover without retransmission. It is enabled only when the
browser advertises <code>audio/red</code> in <code>RTCRtpSender.getCapabilities(&#39;audio&#39;)</code>, which
Chromium does and others vary on; when absent it is skipped silently. RED is
ordered before Opus in codec preferences.</p>
<h3 id="sender-parameters">Sender parameters</h3>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td><code>maxBitrate</code></td>
<td>40000 bps</td>
<td>Headroom above the 32 kbps Opus target for RED redundancy</td>
</tr>
<tr>
<td><code>priority</code></td>
<td><code>high</code></td>
<td>Audio wins bandwidth arbitration against video within the connection</td>
</tr>
<tr>
<td><code>networkPriority</code></td>
<td><code>high</code></td>
<td>DSCP hint so audio is prioritised on the wire</td>
</tr>
</tbody></table></div>
<p>Audio is never throttled by the adaptation controller. Under sustained loss the
video degrades and speech continues.</p>
<h2 id="video">Video</h2>
<p>Codec preference order is VP9, AV1, H.264, VP8, applied through
<code>setCodecPreferences</code>. Retransmission and FEC codecs are kept after the media
codecs so they still function. VP9 and AV1 provide scalable coding; H.264 and VP8
do not.</p>
<h3 id="scalable-coding-rather-than-simulcast">Scalable coding rather than simulcast</h3>
<p>This is a one-to-one connection with a single receiver, so one encoding with SVC
is the right tool: a single stream that degrades by spatial or temporal layer. It
is applied through <code>sender.setParameters</code> and needs no <code>addTransceiver</code> or rid
configuration, which keeps it away from the media path that actually works.</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Codec</th>
<th>scalabilityMode</th>
<th>maxBitrate</th>
<th>degradationPreference</th>
</tr>
</thead>
<tbody><tr>
<td>VP9</td>
<td><code>L3T3_KEY</code> (3 spatial, 3 temporal, key-aligned)</td>
<td>1.5 Mbps</td>
<td><code>balanced</code></td>
</tr>
<tr>
<td>AV1</td>
<td><code>L1T3</code></td>
<td>1.2 Mbps</td>
<td><code>maintain-framerate</code></td>
</tr>
<tr>
<td>H.264, VP8</td>
<td>none</td>
<td>1.5 Mbps</td>
<td><code>balanced</code></td>
</tr>
</tbody></table></div>
<p><code>networkPriority</code> is <code>medium</code>, below audio. If a browser rejects the scalability
mode, which Firefox and Safari do in places, <code>configureVideoSender</code> retries with
a plain encoding.</p>
<h3 id="why-media-is-attached-with-addtrack">Why media is attached with addTrack</h3>
<p>An explicit <code>addTransceiver({ sendEncodings })</code> path was built and then removed,
because it broke media on real devices in two ways. On the answering side,
reusing the transceiver created by <code>setRemoteDescription</code> rejected the SVC
parameters outright. On repeat and role-reversed calls, the reused transceiver
directions desynchronised: the call connected and <code>ontrack</code> fired, but no media
flowed.</p>
<p>What ships instead attaches media with <code>addTrack</code>, reused across calls through
<code>replaceTrack</code>, and lets the browser manage transceiver direction. This is what
keeps audio and video flowing across reversed and repeated calls.</p>
<p>Multi-rid simulcast primitives (<code>buildVideoSendEncodings</code>) are kept and tested for
a future group-call path, but are not wired into the one-to-one flow. They need
<code>addTransceiver({ sendEncodings })</code>, which requires the problems above to be
solved first, ideally with a two-connection browser test rig that is not set up.
The adaptation controller is already simulcast-aware for when that lands.</p>
<h2 id="transport-feedback">Transport feedback</h2>
<p>The call m-lines need RTCP feedback and header extensions present. Most browsers
emit them already, so this is an idempotent safety net:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Media</th>
<th>RTCP feedback</th>
<th>Header extension</th>
</tr>
</thead>
<tbody><tr>
<td>Video</td>
<td><code>transport-cc</code>, <code>nack</code>, <code>nack pli</code>, <code>ccm fir</code>, <code>goog-remb</code></td>
<td>transport-wide congestion control</td>
</tr>
<tr>
<td>Audio</td>
<td><code>transport-cc</code>, <code>nack</code></td>
<td>transport-wide congestion control</td>
</tr>
</tbody></table></div>
<p>These are added only when missing, never duplicated, and applied only to primary
codecs. Transport-wide congestion control is what feeds the bandwidth estimator
that adaptation reads.</p>
<p>Rewritten local SDP is applied with progressive fallback: full rewrite, then Opus
only, then raw. A browser that rejects an injected line cannot break the call.</p>
<h2 id="adaptation">Adaptation</h2>
<p><code>NetworkAdaptationController</code> samples <code>pc.getStats()</code> every second and reacts:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Condition</th>
<th>Action</th>
</tr>
</thead>
<tbody><tr>
<td>Loss above 10 percent, or round trip above 300 ms</td>
<td>Reduce video <code>maxBitrate</code> by 20 percent, floor 100 kbps</td>
</tr>
<tr>
<td>Loss below 3 percent and round trip below 150 ms, sustained 5 samples</td>
<td>Raise video <code>maxBitrate</code> by 10 percent, up to the ceiling</td>
</tr>
<tr>
<td><code>qualityLimitationReason</code> is <code>cpu</code></td>
<td>Scale resolution down by 1.5, bitrate unchanged</td>
</tr>
</tbody></table></div>
<p>Every change goes through <code>sender.setParameters</code>. There is no renegotiation and
no track restart, so adaptation is invisible to the call. Audio is never touched.</p>
<p>The decision function and the stats parsing are pure and unit-tested against
recorded <code>getStats</code> output.</p>
<h3 id="quality-indicator">Quality indicator</h3>
<p>The same sample produces a coarse label shown in the call interface:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>Label</th>
<th>Condition</th>
</tr>
</thead>
<tbody><tr>
<td>Excellent</td>
<td>Loss below 3 percent and round trip below 150 ms</td>
</tr>
<tr>
<td>Good</td>
<td>Loss below 7 percent and round trip below 250 ms</td>
</tr>
<tr>
<td>Fair</td>
<td>Loss below 15 percent and round trip below 400 ms</td>
</tr>
<tr>
<td>Weak</td>
<td>Anything else</td>
</tr>
</tbody></table></div>
<p>It appears in the voice overlay, the video top bar, and as compact bars in the
minimized widget, and stays hidden until the first sample has data.</p>
<h2 id="verifying-a-change">Verifying a change</h2>
<p>Unit tests cover SDP rewriting, video codec selection and the adaptation
decision:</p>
<pre><code class="language-bash">npm test
</code></pre>
<p>For anything touching media in practice, open <code>chrome://webrtc-internals</code> during
a call and check that:</p>
<ul>
<li>outbound audio shows Opus with the fmtp parameters above, appearing as <code>red</code>
and <code>opus</code> on Chromium</li>
<li>throttling the link steps outbound video <code>targetBitrate</code> down within a second
or two and recovers when the link clears</li>
<li>audio bitrate holds steady while video adapts</li>
<li>the in-call indicator moves through Fair and Weak as the link degrades</li>
</ul>
<nav class="more">
<h2>More documentation</h2>
<ul>
<li><a href="/docs/">Documentation</a></li>
<li><a href="/docs/faq/">Frequently asked questions</a></li>
<li><a href="/docs/architecture/">Architecture</a></li>
<li><a href="/docs/cryptography/">Cryptography</a></li>
<li><a href="/docs/descriptor-sbq2/">SBQ2 — connection descriptor v2</a></li>
<li><a href="/docs/configuration/">Configuration</a></li>
<li><a href="/docs/api/">API Notes</a></li>
<li><a href="/docs/contributing/">Contributing</a></li>
<li><a href="/docs/use-policy/">Use policy and limitations</a></li>
</ul>
</nav>
</div>
</body>
</html>