<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hotori</title>
    <description>The latest articles on DEV Community by Hotori (@hotoriplace).</description>
    <link>https://dev.to/hotoriplace</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4218212%2F42d7e5ec-92f5-47c5-8637-c72f83782b46.png</url>
      <title>DEV Community: Hotori</title>
      <link>https://dev.to/hotoriplace</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hotoriplace"/>
    <language>en</language>
    <item>
      <title>How we deliver voice only to people nearby in a 2D virtual office (SFU subscription permissions, not mixing)</title>
      <dc:creator>Hotori</dc:creator>
      <pubDate>Sat, 31 Oct 2026 03:59:44 +0000</pubDate>
      <link>https://dev.to/hotoriplace/how-we-deliver-voice-only-to-people-nearby-in-a-2d-virtual-office-sfu-subscription-permissions-40nl</link>
      <guid>https://dev.to/hotoriplace/how-we-deliver-voice-only-to-people-nearby-in-a-2d-virtual-office-sfu-subscription-permissions-40nl</guid>
      <description>&lt;p&gt;In our 2D virtual office, voice reaches only nearby people because the media server refuses to forward it to anyone else. The app server works out who is near whom and gives each speaker a list of allowed listeners. Each speaker's client passes that list to LiveKit, our SFU, as track subscription permissions, so nothing is mixed on the server and a modified listening browser cannot pull a voice it was not given.&lt;/p&gt;

&lt;p&gt;An SFU (selective forwarding unit) forwards each person's tracks instead of combining them. The stack here is a Python (FastAPI) WebSocket server, a React and Phaser client, and self-hosted LiveKit, which is Apache-2.0 open source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not mix audio on the server?
&lt;/h2&gt;

&lt;p&gt;A mixer gives you privacy for free. The server decodes every microphone and encodes one mix per listener, so each browser only ever receives its own mix. There is no other voice to steal.&lt;/p&gt;

&lt;p&gt;The price is one mix per listener. On a map, people rarely share the same set of voices at the same volumes, so nearly every listener needs separate decode, mix, and encode work.&lt;/p&gt;

&lt;p&gt;An SFU does none of that. It forwards encoded packets, each voice stays a separate track, and the browser sets the volume per track. Each office is one LiveKit room, so walking around only changes subscriptions.&lt;/p&gt;

&lt;p&gt;The trade-off is that the free privacy boundary is gone. Every browser in the room is one request away from every track. You have to build the boundary yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the server decide who is near?
&lt;/h2&gt;

&lt;p&gt;The client sends only movement. The server owns every position and rejects a move of more than 2 tiles or into a wall. Positions go out on a 100 ms tick, and after each tick with movement the server recomputes &lt;code&gt;near&lt;/code&gt;: the set of people whose voice should reach a given listener. Distance is Chebyshev: &lt;code&gt;max(|dx|, |dy|)&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where you are&lt;/th&gt;
&lt;th&gt;Your &lt;code&gt;near&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Volume&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Open floor&lt;/td&gt;
&lt;td&gt;The 8 closest people within 3 tiles, excluding people in meeting rooms&lt;/td&gt;
&lt;td&gt;1.0 at 0 to 1 tiles, 0.7 at 2, 0.45 at 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meeting room&lt;/td&gt;
&lt;td&gt;Everyone in the same room, no cap&lt;/td&gt;
&lt;td&gt;Always 1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Focus desk&lt;/td&gt;
&lt;td&gt;Nobody, and you are in nobody's &lt;code&gt;near&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No voice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server sends &lt;code&gt;near&lt;/code&gt; only when it changes. Note the cap: it makes &lt;code&gt;near&lt;/code&gt; directional. In a crowd, you can be in my 8 closest while I am not in yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 1: the listener chose
&lt;/h2&gt;

&lt;p&gt;The client joined with &lt;code&gt;autoSubscribe: false&lt;/code&gt; and subscribed to the people in &lt;code&gt;near&lt;/code&gt; (condensed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Version 1: the listener decides alone&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;participant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trackPublications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setSubscribed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nearIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;memberIdOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;participant&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looked right on screen, but it trusted the listener's browser. By default, any published track can be subscribed to by all participants. Calling &lt;code&gt;setSubscribed(true)&lt;/code&gt; from the developer tools would let you hear someone at the far end of the map. The access token cannot help: its grants cover the whole room, and proximity changes as people walk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 2: the speaker's permission, enforced by the SFU
&lt;/h2&gt;

&lt;p&gt;LiveKit lets a publisher restrict who may subscribe to its tracks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localParticipant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTrackSubscriptionPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// do not allow everyone&lt;/span&gt;
  &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;participantIdentity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u42&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;allowAll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="c1"&gt;// allow only these&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone left off the list gets nothing from the SFU, whatever their browser does. The call comes from the speaker's client, but the decision should not. Positions live on the server, so the server computes each list and pushes it down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Server: invert &lt;code&gt;near&lt;/code&gt; to get &lt;code&gt;allow&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;allow&lt;/code&gt; is the set of people who may receive my voice and video. It is not my &lt;code&gt;near&lt;/code&gt;. Because &lt;code&gt;near&lt;/code&gt; is directional, copying it would admit people who should not hear me and block people who should. The correct set is everyone whose &lt;code&gt;near&lt;/code&gt; contains me, so the server inverts all the &lt;code&gt;near&lt;/code&gt; sets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compute_allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gmap&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;speaker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;near&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;speaker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# In a meeting room, add everyone in the same room
&lt;/span&gt;    &lt;span class="c1"&gt;# so that allow cannot disagree with near
&lt;/span&gt;    &lt;span class="n"&gt;meetings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zone_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meeting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;meetings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;meetings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server recomputes &lt;code&gt;allow&lt;/code&gt; with &lt;code&gt;near&lt;/code&gt;. It sends &lt;code&gt;{"t": "allow", "ids": [...]}&lt;/code&gt; only to members whose list changed, plus once right after they join.&lt;/p&gt;

&lt;h3&gt;
  
  
  Client: pass the list to the SFU
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;applyAllow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// null (not received yet, or WebSocket down) means: allow nobody&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localParticipant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTrackSubscriptionPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;participantIdentity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`u&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;allowAll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;allowAll: true&lt;/code&gt; covers every track a speaker publishes: microphone, camera, and screen share. This matters because LiveKit's reference notes that track-level grants do not extend to tracks published later.&lt;/p&gt;

&lt;p&gt;The listening side did not change: &lt;code&gt;near&lt;/code&gt; still drives &lt;code&gt;setSubscribed&lt;/code&gt;. Subscription says what a listener wants. Permission says what a listener may have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six pitfalls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The default is open
&lt;/h3&gt;

&lt;p&gt;Right after joining, the first &lt;code&gt;allow&lt;/code&gt; has not arrived, and LiveKit's default lets all participants subscribe. A microphone published in that gap is open to the whole room. So the client sets "allow nobody" before it joins. The SDK sends that on connect, and we send the current list again before publishing the microphone.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A dropped WebSocket must revoke everything
&lt;/h3&gt;

&lt;p&gt;While the WebSocket is down, the client cannot hear about revocations, so a stale permission could outlive its reason. On disconnect the client discards &lt;code&gt;allow&lt;/code&gt; and allows nobody. The next &lt;code&gt;welcome&lt;/code&gt; does not restore it; only a fresh &lt;code&gt;allow&lt;/code&gt; does. A network blip can cut your voice for a moment, and we accept that.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Send &lt;code&gt;allow&lt;/code&gt; before &lt;code&gt;near&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The server sends &lt;code&gt;allow&lt;/code&gt; first and &lt;code&gt;near&lt;/code&gt; second. A revocation reaches the SFU sooner, and someone walking up is more likely to be permitted before their browser asks to subscribe.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. An old &lt;code&gt;allow&lt;/code&gt; must not land last
&lt;/h3&gt;

&lt;p&gt;Sending is async, so another recompute can run during an &lt;code&gt;await&lt;/code&gt;. If each pass sent its own snapshot, a stale list could arrive last and restore a revoked permission. So the server computes and assigns with no &lt;code&gt;await&lt;/code&gt; in between, then reads the current value at send time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;allow_changed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# m.allow is read here, at send time
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_id_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whichever pass sends last sends the newest value.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Permission can arrive late
&lt;/h3&gt;

&lt;p&gt;A listener can ask for a track before the speaker's permission lands. LiveKit connects the subscription on its own once permission arrives. As a safety net, the client waits 600 ms after a grant or a failed subscription, then asks again if it still wants the track, up to 5 times per track (simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stillWanted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;desired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="c1"&gt;// still in near&lt;/span&gt;
  &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trackPublications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;pub&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSubscribed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permissionStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;TrackPublication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PermissionStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Allowed&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stillWanted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setSubscribed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. Meeting rooms need one rule for both lists
&lt;/h3&gt;

&lt;p&gt;Meeting rooms first inherited the cap of 8, so from the ninth person on, voices could not be heard. We removed the cap there. &lt;code&gt;near&lt;/code&gt; and &lt;code&gt;allow&lt;/code&gt; now both mean everyone in the room, and nobody outside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do we test it?
&lt;/h2&gt;

&lt;p&gt;Most of this needs no media server. The server tests use a fake WebSocket that records messages in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On the open floor, &lt;code&gt;allow&lt;/code&gt; is the inverse of &lt;code&gt;near&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;With 8 people packed around a listener, a speaker 3 tiles away falls outside the cap, so that speaker's &lt;code&gt;allow&lt;/code&gt; leaves the listener out, but not the reverse.&lt;/li&gt;
&lt;li&gt;In a meeting room of 12, everyone reaches everyone and someone just outside the door reaches nobody inside. Whoever walks out drops from the other 11 lists.&lt;/li&gt;
&lt;li&gt;A focus desk gives empty lists in both directions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;allow&lt;/code&gt; arrives before &lt;code&gt;near&lt;/code&gt;, and a volume-only change resends &lt;code&gt;near&lt;/code&gt; but not &lt;code&gt;allow&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To test pitfall 4, we stall one socket so that a second recompute overtakes the first (condensed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SlowWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FakeWS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;send_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# stall after the first message
&lt;/span&gt;
&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SlowWS&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh_near&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# let it reach the stall
&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;                    &lt;span class="c1"&gt;# b walks away
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh_near&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# a newer pass overtakes
&lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client tests stay off the network too. They fake LiveKit's connection and log, in order, what would go to the SFU. They check that "allow nobody" comes before the microphone, that a WebSocket drop revokes everything until a new &lt;code&gt;allow&lt;/code&gt; arrives, that the last list is re-sent on every rejoin, and that retries stop at 5 and skip anyone outside &lt;code&gt;near&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Finally, with real audio on a local setup, a far-away browser forced a subscription and received 0 bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does this not protect?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A speaker's own voice.&lt;/strong&gt; The speaker's client applies the permissions, so a modified speaker browser can open its own voice to anyone. It still cannot reach anyone else's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walking up to listen.&lt;/strong&gt; Anyone nearby can hear. The server rejects jumps, so getting close means walking there, visible on the map.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata.&lt;/strong&gt; Everyone in the room still sees who is present and whose microphone is muted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The SFU itself.&lt;/strong&gt; This design controls who receives a track. It does not hide audio from whoever runs the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to take from this
&lt;/h2&gt;

&lt;p&gt;If you build proximity voice on an SFU, treat subscription as a preference and permission as the boundary. Compute &lt;code&gt;allow&lt;/code&gt; on the server by inverting &lt;code&gt;near&lt;/code&gt;, not by copying it. And fail closed whenever the client cannot be sure: before the first &lt;code&gt;allow&lt;/code&gt;, and while the control channel is down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Camera &amp;amp; microphone, "Track permissions" section (LiveKit) — &lt;a href="https://docs.livekit.io/transport/media/publish/" rel="noopener noreferrer"&gt;https://docs.livekit.io/transport/media/publish/&lt;/a&gt; (retrieved October 31, 2026)&lt;/li&gt;
&lt;li&gt;Subscribing to tracks (LiveKit) — &lt;a href="https://docs.livekit.io/transport/media/subscribe/" rel="noopener noreferrer"&gt;https://docs.livekit.io/transport/media/subscribe/&lt;/a&gt; (retrieved October 31, 2026)&lt;/li&gt;
&lt;li&gt;LocalParticipant class reference (LiveKit) — &lt;a href="https://docs.livekit.io/reference/client-sdk-js/classes/LocalParticipant.html" rel="noopener noreferrer"&gt;https://docs.livekit.io/reference/client-sdk-js/classes/LocalParticipant.html&lt;/a&gt; (retrieved October 31, 2026)&lt;/li&gt;
&lt;li&gt;livekit/livekit repository, Apache License 2.0 (LiveKit) — &lt;a href="https://github.com/livekit/livekit" rel="noopener noreferrer"&gt;https://github.com/livekit/livekit&lt;/a&gt; (retrieved October 31, 2026)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;We use this in Hotori (&lt;a href="https://hotoriplace.com/en/" rel="noopener noreferrer"&gt;https://hotoriplace.com/en/&lt;/a&gt;), a voice-first virtual office that runs in the browser, made in Tokyo by a small team.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>livekit</category>
      <category>typescript</category>
      <category>python</category>
    </item>
  </channel>
</rss>
