Сразу разочарую любителей громких фраз: я не делал «защищённый мессенджер», я не думал ни про слежку, ни про прослушку, ни про приватность вообще.

Изначально я хотел лишь одного: иметь возможность быстро и без препятствий созвониться с кем‑либо. А получился голосовой чат, разговор в котором физически нельзя прослушать со стороны, и понял я это уже сильно позже, так что «невозможно прослушать» в заголовке — не цель, а побочка.

Дальше расскажу, как оно вышло и где у этой невозможности честные границы.


Началось всё очень банально: обычный вечер, обычный разговор в Discord, но в какой‑то момент прилетела задержка доставки голоса в 3–5 секунд, разговаривать стало невозможно.

Минут сорок мы пытались исправить ситуацию: меняли серверы VPN, перезапускали «Запрет» и меняли стратегии, а отчаявшись — переустанавливали сам Discord. Задержка порой пропадала, но всегда возвращалась.


Пошли искать альтернативы: Телемост со своим корпоративным интерфейсом, существующий только в вебе — отмели почти сразу. Дальше веселее: в РФ большая часть альтернатив без VPN просто не открывается (не считая откровенно позорных клонов вышеупомянутого Discord), а у TeamSpeak порог входа такой, что проще приехать к собеседнику лично: зарегистрируйся, поставь приложение, найди публичный сервер и разберись, как подключиться. Часа за полтора мы перебрали всё, что попадалось на глаза, и ни один из сервисов не смог закрыть самую простую задачу: прямо сейчас созвониться и услышать друг друга без задержки и необходимости дирижировать VPN/сервисами обхода.

В конце концов, последние надежды канули:

Раз альтернатив нет — сделаем.

За ночь накидал прототип — задумка была примитивная, «ничего лишнего»: зашёл и говоришь. Без регистрации с кодом подтверждения на почту, без онбординга, без поп‑апов «разрешите уведомления», а через пару недель дошло, что это самое «ничего лишнего» и есть главная фишка. Эфемерность и отсутствие всей современной мишуры, которую я выкинул из лени и минимализма, оказались ровно тем, чего у остальных нет.

Проект был назван «Void», сейчас он уже крутится в проде и выполняет ровно ту функцию, для которой и был создан. Дальше расскажу, как он устроен внутри, почему из‑за одного решения он вышел одновременно быстрым, приватным и неудобным для прослушки, и на чём я по дороге обжёгся.


Почему вообще лагало

Если разобраться, откуда вообще взялась задержка — ответ будет довольно скучным. Дело всё в том, что голос идёт не напрямую к собеседнику, а делает крюк через сервер где‑то далеко не в России. Мой голос летел на дата‑центр Discord, оттуда — другу, а его ответ возвращался тем же путём. Лишние тысячи километров в каждую сторону, а VPN эту историю только усугублял, добавляя ещё один крюк сверху.

И вот эта штука, медиасервер посреди разговора — это корень вообще всего. Раз весь звук идёт через него, значит технически голос полностью в их руках: хочешь пиши, хочешь анализируй, а если вежливо попросят — поделись. К тому же, раз есть аккаунт — есть за что зацепить историю, модерацию, а с недавних пор и проверку возраста по паспорту. Убери сервер из середины разговора, и половина этих проблем отваливается разом, просто потому что исчезает их источник.

Так что вопрос был чисто инженерный: как сделать, чтобы двое говорили напрямую, а сервер только сводил их — и более не участвовал. Оказалось, ровно для этого в браузере давно есть WebRTC, нужно только всё собрать.


Сервер сводит — и больше не участвует

Серверная часть Void — это Node.js, WS и немного Express. Пока идёт звонок, сервер делает ровно одну вещь: сводит участников. Принимает от одного описание соединения (SDP), передаёт другому, гоняет между ними ICE‑кандидатов — то есть сообщения в духе «вот так до меня можно дозвониться». Пара килобайт служебного текста на установление связи, после этого сервер из разговора выпадает.

Базы данных — нет. Не «есть, но мы туда правда не смотрим», а нет как явления. Комнаты лежат в обычном Map в памяти процесса: код комнаты и список тех, кто в ней сидит. Вышел последний участник — с ним пропала и запись из памяти. На диск пишется один json с обезличенной статистикой: сколько комнат за день, сколько соединений встало напрямую, сколько ушло через relay. Ни ников, ни сообщений, ни записей звонков там нет, потому что до сервера они не доезжают.

Исходный код

Если совсем буквально, вся «база данных» сервера — это один Map в памяти процесса:

Map {
  roomCode => {
    users: Map { userId => { ws, nickname, mic, sound, screen } },
    cleanupTimer: setTimeout id | null
  }
}

Вышел последний участник —rooms.delete(code), и от комнаты не осталось ровным счётом ничего.

Отсюда и «невозможно прослушать» из заголовка. Прослушать разговор со стороны попросту негде, его нет на сервере. В базе, которой нет, тоже нет. А между собой собеседники шифруют поток напрямую: в WebRTC это DTLSSRTP, ключи согласуются между браузерами, посередине их никто не держит. Даже когда пара из‑за кривой сети идёт через relay, релей гоняет уже зашифрованные пакеты и расшифровать их не может. Метаданные, кто с кем соединился, сервер видит в момент знакомства, но никуда не пишет: комната живёт в памяти и исчезает с последним участником.

И тут — честная сноска: «невозможно прослушать» относится к пути между собеседниками, а не к их устройствам. Если чьё‑то устройство заражено, никакой P2P не спасёт: звук снимут прямо там, где он звучит или произносится, ещё до всякого шифрования. Криптография защищает канал, а не дырявый эндпоинт. Void убирает возможность прослушать в пути, но если противник уже сидит у тебя в системе — он услышит разговор, и архитектурой это не лечится.


Почему в комнате мало народу

Раз сервер звук не микширует, участники обязаны соединяться друг с другом сами. Для двоих это одно соединение, для группы «каждый с каждым» число соединений растёт как N×(N−1)/2: впятером это 10, вдесятером уже 45, и каждый шлёт свой звук всем остальным отдельным потоком.

Поэтому комнаты держатся маленькими: потолок = десять человек, и это, к сожалению, цена решения. Если нужна комната на сотню — придётся ставить SFU в середину, а он опять начнёт видеть поток. SFU я ставить не хотел, ради него пришлось бы выкинуть ровно то, из‑за чего всё затевалось.


Perfect negotiation, или где WebRTC кусается

Во время тестов оказались нередкими ситуации, когда обе стороны могут одновременно решить обновить соединение и одновременно швырнуть друг другу offer. Называется это glare, и не ожидающий такого явления код в этот момент погибает: оба ждут ответа, который никто не пришлёт.

Лечится штукой из спеки W3C под названием perfect negotiation. Каждому соединению назначается роль: вежливый и невежливый, и считается она симметрично из сравнения id — у двух сторон роли выходят противоположными без всякой договорённости между ними.

Исходный код

Чей ID «больше» как строка, тот и вежливый. У собеседника сравнение зеркальное, так что вежливым выходит ровно один:

peer._polite = String(clientId) > String(userId);

Вежливый при столкновении откатывает свой offer и принимает чужой, а невежливый — продавливает свой:

const offerCollision =
    peer.signalingState !== "stable" && !peer._isSettingRemoteAnswerPending;
const ignoreOffer = !peer._polite && offerCollision;
if (ignoreOffer) return;              // невежливый: держим свой offer

await peer.setRemoteDescription(new RTCSessionDescription(data.offer));
await peer.setLocalDescription();     // вежливый: уступаем, implicit rollback

ДальшеsetLocalDescription()без аргументов сам понимает, что сейчас уместно, offer или answer.

Без perfect negotiation групповой звонок разваливается ровно в тот момент, когда двое одновременно что‑то переключили (а они переключат), и я наступил на это классически: на двух вкладках всё работает, а в реальной комнате — зависшие коннекты на ровном месте. Вывод довольно банальный: «работает у меня» и «работает в реальности» в WebRTC — это совершенно разные вещи.


NAT, VPN и почему до собеседника ещё надо дозвониться

Чтобы двое соединились напрямую, каждому надо узнать свой внешний адрес и продавить NAT. Этим занимаются STUN‑серверы, лёгкие и публичные, они просто отвечают «вот как ты выглядишь снаружи». Дома у большинства этого хватает, и звук идёт по кратчайшему пути, который вообще есть между вами. Кратчайший путь — это и есть ключ к минимальной задержке: слышно так, будто человек в соседнем доме, потому что звук не мотается через дата‑центр в другой стране.

Но всё ломается на симметричном NAT и особенно на CG‑NAT у мобильных операторов, где за одним внешним адресом сидят сотни абонентов, там напрямую установить соединение попросту невозможно. По моей статистике после десятка часов тестов на таких сетях ~23% пар не соединяются напрямую и уходят на запасной вариант — TURN‑сервер (у меня это coturn), который ретранслирует трафик, когда прямого пути нет. Да, для такой пары звонок физически идёт через мой сервер. Но coturn пересылает уже зашифрованные пакеты (DTLS‑SRTP) и расшифровывать их не умеет. Можно, конечно, выключить TURN совсем, но в таком случае на STUN‑only отваливаются те самые ~23% сложных пар, поэтому в проде он поднят.

Исходный код

Отдельной базы под TURN тоже нет: credentials короткоживущие и считаются на лету по стандартной схеме coturn:

const expiry = Math.floor(Date.now() / 1000) + 3600;   // TTL = 1 час
const username = `${expiry}:${userId}`;
const credential = crypto
    .createHmac("sha1", TURN_SECRET)
    .update(username)
    .digest("base64");

И про VPN: если сам сайт заблокирован и открывается он только с помощью VPN — туннель будет использоваться только чтобы открыть страницу и обменяться сигналингом — всего пара секунд при подключении. Сам разговор дальше идёт напрямую и VPN его не трогает: пинг звонка не зависит от того, через какую страну у тебя туннель, потому что голос по нему не идёт.


Когда сеть подводит — как отрабатывать?

P2P-соединение хрупкое, и причин тому может быть масса: переключился вайфай, потерялись пакеты, оператор перекинул маршрут, связь рвётся. Без механизма отработки таких случаев это был бы обрыв звонка, поэтому поверх WebRTC пришлось построить машину восстановления.

Работает это примерно так: статус disconnected — это ещё не конец, мы терпеливо ждём пару секунд, часто сеть чинится сама. Если не помогло — сторона делает ICE‑restart, ищет маршрут заново. Не помогло за несколько секунд — пересобирает соединение с нуля, вторая сторона по флагу делает то же самое. Отдельно висит watchdog на дохлые коннекты: раз в две секунды смотрит счётчик входящих пакетов, и если соединение по статусу живое, а пакетов ноль дольше пяти секунд, не ждёт таймаут в полминуты, а пересобирает сразу. На практике это ловит «вроде подключено, а связи нет» заметно быстрее.

Стоит это одной неприятной секунды: в момент пересборки звук проваливается на секунду‑две. Микрофон при этом не теряется, разговор продолжается.


Что досталось от браузера почти даром

Раз всё живёт в вебе, часть вещей вышла копеечной ценой. Шумодав — это RNNoise, собранный в WASM и крутящийся в AudioWorklet: клавиатура, кулер и улица режутся прямо на клиенте, ещё до отправки собеседнику. Чат и файлы идут по тому же P2P, через RTCDataChannel, бинарными кусками по 60 КБ (чуть ниже потолка SCTP), файлы до 100 МБ. Демонстрация экрана со звуком и в FullHD, тем же прямым каналом. На сервер ничего из этого не заходит, гарантии те же, что и у голоса.


Что в итоге не идеально

Без раздела с минусами — никуда:

  1. Mesh не тянет масштаб. Десять человек это потолок, и поднять его без перехода на SFU нельзя, а SFU убьёт главное свойство. Это не «руки не дошли», это развилка, и я сознательно свернул в сторону приватности.

  2. UserId подделываемый. Клиент сам придумывает себе id, сервер только проверяет формат. Теоретически можно назваться чужим id. Практически же воровать нечего: между сессиями никакого состояния нет, аккаунтов нет. Строить крепость вокруг пустого сарая я не стал.

  3. Пушей в вебе нет. Закрыл вкладку — звонка не услышишь, тебя нечем «дёрнуть», потому что нет ни аккаунта, ни твоей сессии на сервере. В десктопной версии частично лечится треем, в вебе — никак.

  4. Рестарт сервера убивает комнаты. Обратная сторона отсутствия БД. Для созвона на двадцать минут ок, но попал в момент деплоя — заходишь заново.

  5. Десктоп только на Windows. Приложение на Tauri есть под винду, macOS и Linux пока нет. Веб работает везде, где работает WebRTC.

Всё из списка выше — не провал, а скорее ценник за конструкцию, в которой серверу про тебя нечего знать. Но и говорить, что всё идеально, было бы враньём.

Честная оговорка — это не «убийца Discord»

Discord — про сообщества, сервера, роли, вечный онлайн, и это отдельная задача, которую Void не решает и не пытается. Void — это скорее «быстро созвонился и разошлись», ближе к телефонному звонку, чем к соцсети.


Зачем это всё?

Весь проект вырос из одной фундаментальной проблемы: хотелось просто поговорить, а пришлось воевать с задержкой и искать, куда сбежать. Так что делал я его в первую очередь для себя и пары друзей, а «приватность» получилась побочным эффектом, ведь выкинув из схемы всё лишнее, вышло что собирать нечего — потому что нечем.

Потрогать Void можно в браузере на void‑room.space, без установки и регистрации: создаёшь комнату, отправляешь код собеседнику — и говорите. Ровно то, чего мне тогда и не хватило.

Код лежит открыто на GitHub — буду рад звёздам и issue.

P.S.

За кулисами осталось много вещей, которые не уместились в статью: recovery‑машина при обрывах, эхо, screencast‑ducking, десктоп‑версия на Tauri. Проблем за месяцы разработки набралось прилично, и если будет спрос — с удовольствием разберу самые интересные по отдельности. Peace!

Комментарии (11)


  1. SolidSnack
    08.07.2026 12:10

    Звонки не пробовал, но сайт выглядит прикольно, вместе с ИИшницей делали?)


    1. jettsba Автор
      08.07.2026 12:10

      Спасибо!
      Да, в становлении итогового дизайн кода во многом помог Claude Design, а немалый вклад в разработку внес Opus 4.8

      Прототип изначально выглядел так


  1. rPman
    08.07.2026 12:10

    Проблему экспоненциальной нагрузки сети в p2p аудио-чатах можно решить круговым алгоритмом... повышается лаг но зато невероятно низкие требования к сети.

    Каждый пользователь соединяется с условно двумя (лучше больше, но алгоритм сильно усложняется, зато повышается надежность) соседями таким образом, что бы образовалось кольцо. Каждый аудио, пакет полученный от соседа, микшируется с имеющимся в буфере, и к нему добавляется флаг - какой участник в него попал, после того как к узлу приходит пакет со всеми участниками, он уходит на прослушивание.

    Алгоритм с 2 соседями и кольцом, очень простой, лаг будет равен сумме интервалов доставки и обработки пакетов на всех узлах. Недостаток, достаточно одному участнику получить проблему со связью, как у всех связь обрывается. Достоинство, итоговая нагрузка на сеть у каждого участника - константа (два пакета), и в принципе центр рекомендован только для координации процесса. Так как лаг - сумма лагов связи между нодами, можно посчитать скорость связи между всеми участниками для всех и перестраивать кольцо с наименьшим лагом.

    Для двух соседей, вместо отправки пакетов в одном направлении по кольцу, делать это можно в обе стороны, т.е. два кольца, дублирование пакета (отбрасывать дублирующие пакеты с меньшим количеством участников или даже продумать алгоритм микширования, при котором повторное наложение звука ничего не портит)

    И универсальное решение - вместо двух соседей, подключаться к большему их числу, вводить хранение недособранных пакетов, таймауты, повторные рассылки... если ввести специально лаг между отправки пакета со звуком от 'плохих узлов' и его озвучки у слушателей, то начиная с некоторого порядка алгоритм будет работать очень эффективно.

    Очень сильно можно это оптимизировать, добавляя в круг рассылки только тех участников, кто генерирует звук (включен микрофон или автоматически определено наличие звука), пакет все еще перемещается по всему кругу участников (слушателей) но помечать флагами и ожидать законченный круг, придется меньше (шансы что одновременно говорят много людей - мало)

    upd. странно, что не могу найти чего то похожего в готовом виде, библиотеки или чата... похоже со времен скайпа, p2p технологии не развиваются и даже сильно деградировали, потому что рынком правят централизованные компании, которым не выгодно 'делать правильно' для потребителя.


    1. jettsba Автор
      08.07.2026 12:10

      Спасибо, корень вы назвали верно, но при таком подходе есть несколько критических преград.

      Во‑первых — лаг будет копиться по кольцу. Микшировать, не выравнивая пакеты — нельзя, а значит на каждом узле нужен джиттер‑буфер, и он (+сетевой хоп) добавляется на каждом узле. В mesh до собеседника один прямой хоп, в кольце голос до дальнего пойдет через всех, плюсом — пакет уйдет на воспроизведение, только собрав всех. Бюджет для комфортной беседы ~150-250 мс, дальше она рассыпается — и уже при 5–6 пирах сумма хопов с буферами его съедает.

      Второе — хрупкость. Основная боль как раз в мобильных сетях и CG‑NAT — узлы моргают постоянно. В mesh сетевые проблемы конкретного пира бьют только по его паре, у остальных всё живет. В кольце же при любом чихе одного узла — пересборка кольца для всех. Два кольца это частично исправляют, но возвращают ту нагрузку и сложность, ради ухода от которых всё затевалось.

      И еще момент: у меня громкость регулируется per‑peer, свой range на каждого. В предложенной архитектуре если смикшировать звук выше по кольцу — все получат общий микс, отдельного человека уже не убавишь. А для демонстрации экрана такая архитектура и вовсе неприменима (изображение не суммируется так же, как и звук).

      P.S: про «после скайпа P2P деградировал» — тут, мне кажется, не только рынок. Централизованный SFU выигрывает на масштабе, устойчивости к CG‑NAT и надёжности; скайповский же P2P был на супернодах‑релеях, и его закопали как раз за нестабильность. Хотя коммерческий интерес тоже никто не отменял)


      1. rPman
        08.07.2026 12:10

        Вы абсолютно правы, алгоритм с кучей недостатков, что бы их решать, нужно его сильно усложнять... Например держать подключения все со всеми и даунгрейдить протокол до классмэического в проблемных случаях, или например пакет застрял на узле, координатор по тайм-аут шлет команду кому куда повторно выслать пакет (например предыдущему перескочить через сбойного), Двойные и тройные круги,.. проблема с общей громкостью (ее можно менять только на отправителе, т е для всех). Рост лага из-за количества участников можно замедлить при переходе от кольца к дереву, или даже графу (сложно с микширование AB+BC = A 2B Ç, и.е. придется запрашивать у B его пакет что бы вычесть...

        Проблема в централизованных сервисов, в их стоимости, бесплатные возможнна только если клиенты - товар, и это отвратительна практика, которая стала нормой


        1. jettsba Автор
          08.07.2026 12:10

          Вы, по сути, сами прошли путь до конца :)
          «держать подключения все со всеми» — это и есть mesh, «координатор, который решает, кому куда повторно кинуть пакет» — это сервер, вернувшийся в контур, а «двойные и тройные круги» — это x2 и x3 к полосе, ради экономии которой всё затевалось.

          Но главное — «микширование AB + BC»: чтобы вычесть B из готового микса, нужен отдельный поток B — а если он у вас есть, то и микшировать по дороге было незачем. Плюс каждый узел, который декодирует и кодирует заново, платит задержкой и качеством, поэтому в сети потоки форвардят (это и есть SFU), а микшируют на эндпоинте, где все составляющие лежат раздельно. Mesh — это и есть микширование на эндпоинте, просто без посредника; отсюда и per‑peer громкость берётся бесплатно.

          Про «бесплатно = клиенты — товар» скорее соглашусь, но, справедливости ради — кто‑то всегда платит, вопрос лишь в размере счёта. Сервер, через который идёт весь медиатрафик, стоит дорого — и счёт закрывают данными пользователей. А стоимость сервера, единственная задача которого — сигналинг, равна стоимости самого простого VPS, и его закрываю я сам. Это не меценатство, просто дёшево, единственная реальная статья расходов — TURN‑трафик для сложных NAT.
          А вырастет — скорее пойду в донаты, чем в сбор данных: даже не из благородства, а потому что собирать нечем :)


          1. rPman
            08.07.2026 12:10

            Делать подключение все со всеми не равно слать данные всем, это просто организация и возможность оперативно детектировать уход узла из сети и управляющие команда (так же громкость).

            По поводу 'слать B что бы вычесть A+2B+C', это на случай когда схема не круг а граф, и там слать нужно только долю от общего трафика и только 1-2 узлам а не всем (но слать придется часто, суммарно будет примерно логарифм от количества нод и повышается при возникновении проблем на одной из нод)


  1. Advisory
    08.07.2026 12:10

    Случайно вдохновились статьёй «Вайбкод челлендж: сгенерируй мессенджер, если «телега» больше не поедет»?

    Жаль её заблочили и ссылки на проекты протухли. Интересный челендж был.


    1. jettsba Автор
      08.07.2026 12:10

      Спасибо за статью, не попадалась. Дух в самом деле схож, но развилки разные — челлендж больше про передачу текста через брокеры, в то время как Void — про голос с собственным сигналингом.
      Но это очередное подтверждение того, что многое уже придумано до нас, и не раз.

      Проект вырос из того самого вечера и строился на ходу, и в тот момент эта статья пригодилась бы мне куда больше, чем всё, что я тогда нагуглил)


  1. debagger
    08.07.2026 12:10

    Раз сервер звук не микширует, участники обязаны соединяться друг с другом сами. Для двоих это одно соединение, для группы «каждый с каждым» число соединений растёт как N×(N−1)/2: впятером это 10, вдесятером уже 45, и каждый шлёт свой звук всем остальным отдельным потоком.

    Почему бы для обхода этого недостатка не назначить одного из пиров главным (мастер-нода) и чтобы все остальные подключались к нему? Уменьшится количество соединений, можно микшировать поток в одном месте и раздавать его всем пирам, плюс появляются возможности управления трансляцией, принудительного отключения пиров, например. Из минусов - нагрузка на мастер-ноду: должна быть хорошая сеть и достаточно ресурсов и, если мастер-нода отвалится то всё.


    1. jettsba Автор
      08.07.2026 12:10

      Вы во многом сами отвечаете на свой же вопрос :) По сути вы описываете MCU/SFU, просто размещённый не на сервере, а на устройстве одного из участников — ровно так работал Skype на супернодах‑релеях, о котором упоминали в комментариях выше.

      В архитектуре звезды микширующая мастер‑нода физически получает чистый звук всех участников, сервер из разговора я убирал ровно ради этого — а тут посредник возвращается, только теперь это устройство одного из участников.

      Нагрузка при таком строении не падает, а концентрируется: соединений действительно меньше (9 против 45 для комнаты на 10 человек), но платим мы не за соединения, а за потоки: мастер принимает 9 и раздаёт 9 разных миксов (каждому — без его собственного голоса), то есть ровно столько же, сколько нёс любой пир в mesh, плюс CPU на джиттер‑буферы. Они же дают и задержку: путь «я → мастер → слушатель» вместо одного прямого хопа. Общий микс вдобавок убивает per‑peer громкость. Не говоря уже о хрупкости всей комнаты, о которой вы и сами упомянули.

      А если рассматривать такую архитектуру как задел для конференций на 50+ участников — то одна нода столько не вытянет, и полсотни человек будут зависеть от чьего‑то ноутбука и качества его сети. Если центральный микшер всё‑таки нужен, его место в дата‑центре, где есть аплоад, аптайм и резерв — а это и есть SFU :)