Распространенная в пандемию ситуация: общаетесь с друзьями или коллегами по Zoom, несколько человек начинают говорить одновременно и… разобрать хоть что-то не представляется возможным. Эта проблема натолкнула нас на идею написать свое приложение для аудиозвонков, где громкость регулируется весьма необычным образом. У каждого пользователя есть свой аватар — кружок на плоскости, который управляется перетаскиванием. Чем ближе аватары пользователей на экране, тем громче они друг друга слышат. Работает ли это? В целом да. Рассказываем, что у нас получилось.
Краткое содержание:
О нас
Привет! Мы второкурсники программы «Прикладная математика и информатика» в Питерской Вышке — Фёдор Громов, Никита Денисов и Алексей Поздняков. Приложение мы делали в рамках семестрового проекта по C++ на первом курсе университета. Мы еще со школы увлекаемся программированием и математикой, но PosiPhone — наш первый серьезный командный проект.
Идея проекта
Идею для проекта нам дал ментор — Всеволод Опарин. Он выпускник СПбАУ, сейчас занимается машинным обучением при небольшом стартапе из Кремниевой Долины. Во время карантина Всеволод много общался с друзьями с помощью разных приложений, но столкнулся с проблемой: если во время звонка говорят сразу несколько человек, трудно разобрать хоть что-нибудь. Поэтому возникла простая идея: сгруппировать людей в кучки, как в реальной жизни, и сделать так, чтобы они могли кочевать от группы к группе, слушая несколько разговоров одновременно.
Так и появился PosiPhone — десктопное клиент-серверное приложение для общения голосом. Его особенность в необычной регулировке звука: пользователь слышит других людей громче или тише в зависимости от их удаленности на экране.
Как это выглядит? Пользователь запускает приложение, видит на экране свой аватар — зеленый кружок — и аватары других участников — красные кружки. Пользователь может перемещать свой аватар, включать/выключать звук, чтобы слышать других участников, а также включать/выключать микрофон, чтобы другие участники слышали его. Как мы уже сказали, громкость звука зависит от расстояния аватаров на экране. Соответственно, если пользователи слишком далеко, они вообще не услышат друг друга.
Целью нашего проекта было выяснить, насколько удобна такая необычная регулировка громкости. А заодно изучить стек технологий, необходимых для реализации проекта.
Структура приложения
Клиент
Начнем описание приложения с клиентской части. Она состоит из трех модулей: графического интерфейса, аудиомодуля и сетевого модуля.
Графический интерфейс. Реализован с помощью паттерна проектирования MVC (Model-View-Controller, Модель-Представление-Контроллер). Когда пользователь передвигает мышкой свой кружок, контроллер фиксирует это событие и передает его в модель. Она поддерживает информацию о пользователях и задает логику графического интерфейса. Изменения модели передаются на представление с помощью механизма сигналов и слотов библиотеки Qt. Главное их преимущество — они потокобезопасны, то есть объекты из разных потоков не будут мешать друг другу во время работы с общими данными.
Сетевой модуль позволяет пользователям обмениваться данными друг с другом.
Аудиомодуль записывает и проигрывает звук.
Подробнее об этих модулях расскажем позже.
Многопоточность
Понятно, что графический интерфейс и аудиомодуль не могут работать одновременно в одном потоке. Поэтому нам потребовалась многопоточность.
Для удобной работы с несколькими потоками ментор посоветовал использовать структуру данных Notification Queue (далее NQ) из библиотеки Facebook Folly. Это асинхронная потокобезопасная очередь, в которую объекты из других потоков могут складывать свои сообщения, где, например, могут лежать новые координаты для кружка или записанный аудиофрагмент. Когда в очередь поступает новое сообщение, она вызывает специальную callback-функцию, в которой это сообщение можно обработать асинхронно. Это значительно упрощает реализацию.
Для обмена информацией между модулями на клиенте была реализована структура Message (на схеме обозначена как CM — Client Message). Когда модуль хочет передать, например, новые координаты кружка или аудиофрагмент модулю из другого потока, он кладет эти данные в структуру Message, и передает это сообщение в соответствующую NQ.
Добавляем сеть
Для передачи данных по сети у нас есть два сетевых модуля: один расположен на клиенте, второй — на сервере. Модули общаются друг с другом через интернет с помощью библиотеки ZMQ. Сериализацию сообщений мы делали с помощью библиотеки boost. При ожидании ответа сервера сетевой модуль ZMQ на клиенте может зависнуть на неопределенное время, например, из-за проблем с соединением. Чтобы это предотвратить, мы также поместили этот модуль в отдельный поток. Поэтому модель и аудиомодуль соединены с сетевым модулем клиента посредством очереди NQ.
Сервер
Сетевой модуль ZMQ на сервере. Хранит в себе актуальную информацию о всех активных клиентах. Также он обменивается данными с микшером. В дополнение к базовой функциональности, сетевой модуль ZMQ на сервере оснащен модулем детектирования пользователей. Он отвечает за обнаружение тех пользователей, которые испытывают проблемы с подключением к интернету. Если сетевой модуль клиента, который отвечает за конкретного пользователя, не отвечает модулю детектирования в течении нескольких секунд, то сервер считает, что пользователь вышел из приложения и обрывает с ним связь.
Микшер. Это модуль, который из аудиофрагментов от разных пользователей генерирует аудиозаписи с громкостями, измененными в зависимости от расстояния. Опять же по причинам, связанным с сетью, мы вынесли его в отдельный поток. Данные для микширования он получает из сетевого модуля ZMQ на сервере. Это взаимодействие реализовано через NQ, но с использованием новых сообщений — Server Message (SM). Мы решили сделать две разные структуры для сообщений на клиенте и на сервере, потому что микшер не использует дополнительную информацию, которая хранится в CM.
Добавляем аудио
Подробнее о микшировании звуков
На этой схеме более подробно показано взаимодействие сетевого модуля на сервере и микшера. Сетевой модуль складывает в NQ сообщения, полученные от клиентов. Там модуль микшера выбрасывает те сообщения, которые пришли слишком поздно из-за задержек сети. Остальные сообщения сортируются по очередям в зависимости от клиента, от которого они пришли. Микшер каждые 50 миллисекунд берет по одному сообщению из каждой очереди и смешивает их в несколько аудиодорожек: каждому пользователю своя в зависимости от расстояния до других пользователей. Смешанные фрагменты обратно отправляются в сетевой модуль.
Подробнее об аудиомодуле на клиенте
Теперь поговорим про аудиомодуль на клиенте. Записывание аудиосообщений тривиально: в буфер постоянно добавляются новые аудиофрагменты и раз в 50 миллисекунд из свежих данных генерируется сообщение, которое отправляется на сетевой модуль клиента.
С проигрыванием история интереснее. Сетевой модуль перенаправляет на аудиомодуль аудиосообщения от других участников. Сообщения перед проигрыванием выравниваются по времени. Это нужно в ситуации, когда есть задержки сети. В этом случае клиенту приходит сразу несколько аудиосообщений. Тогда логично проиграть их не все сразу, а последовательно в том порядке, в котором они записывались. Для этого мы использовали структуру данных Producer Consumer Queue из библиотеки Facebook Folly. Её мы выбрали, потому что она потокобезопасна, но работает как обычная очередь. Для выравнивания мы достаем аудиосообщения каждые 50 миллисекунд из очереди и проигрываем их.
Общая структура приложения
Давайте рассмотрим на примере, какой путь проходят аудиосообщения. Сначала аудиомодуль записывает голос на каждом из клиентов, из которого конструируются сообщения Client Messages (CM). Эти сообщения передаются в сетевой модуль клиента, откуда информация по сети идет на сервер. Сетевой модуль сервера конструирует Server Messages (SM) на основе полученной информации и передаёт их в микшер. Микшер отдает обратно смешанные звуковые SM. Сетевой модуль сервера пересылает результаты работы микшера на каждый из клиентов. На сетевом модуле конструируются CM и передаются в аудиомодуль, где сообщения и проигрываются.
Работа над приложением
Мы разделили проект на три части: Никита отвечал за сетевую часть, Лёша взялся за GUI и запись/воспроизведение звука на клиенте, а Федя писал микшер звука на сервере.
Сначала перед нами стояла задача написать приложение с базовым функционалом: на экране двигаются свой и чужие кружки, звука нет.
Сеть
Чтобы лучше ознакомиться с темой, Никита прошел курс от Google по сетям на Coursera. После подготовки пришло время определиться с технологией. Мы встали перед выбором: Socket.IO, boost или ZMQ? С помощью первой библиотеки достаточно просто написать клиент-серверное приложение, но у нас были опасения, что скорости может не хватить, так как она была разработана в основном под JS. Второй библиотекой пользовались многие наши одногруппники в своих проектах, но там надо было работать почти с голыми сокетами. В то время как в библиотеке ZMQ уже реализованы многие удобные паттерны обмена сообщений. Как вы, наверное, догадались, мы остановились на третьем варианте. Чтобы понять на простом примере особенности обмена сообщениями по сети, Никита написал простой чат с помощью ZMQ, как ему посоветовал ментор.
Для работы над проектом нам понадобился сервер с публичным IP-адресом, где мы могли бы запустить общедоступный сервер. Спасибо нашему преподавателю по C++ Егору Суворову за то, что он предоставил такой сервер.
Графический интерфейс
Для GUI мы выбрали Qt. Причин несколько Во-первых, эта библиотека написана на C++, который мы изучали, к тому же у нас проводилось несколько семинаров именно по Qt. Также это удобная библиотека для разработки интерфейса пользователя с подробной документацией и большим сообществом разработчиков. Изначально у нашего ментора была задумка реализовать этот проект в вебе. Но в таком случае пришлось бы в спешном порядке изучать JavaScript/TypeScript и несколько других фреймворков, из-за чего мы могли бы не успеть к защите.
Так как проект разрабатывался параллельно, во время написания графической части сетевая часть еще была не готова. Поэтому мы заменили ее на mock-объект.
Для записи звука выбрали модуль Qt Multimedia, а именно классы QAudioInput и QAudioOutput. Они позволяют сохранять записанное аудио в буфер и проигрывать звук из него, что удобно для передачи аудио по сети.
Микшер
Фёдор реализовал базовые функции этого модуля. Перед добавлением в проект микшер был протестирован локально на аудиозаписях, скачанных из интернета. Для упрощения работы с аудиофайлами воспользовались библиотекой AudioFile: она декодирует .wav файл на диске в его структурное представление в программе. После успешного тестирования на нескольких файлах начали добавление модуля в проект.
И как мы все это объединяли
Сеть и GUI. Сначала мы решили объединить сеть с GUI. С интерфейсом пересекается клиентская часть приложения, поэтому Никита реализовал функционал, который диктовал GUI. Примерно через неделю попыток у нас получилось добиться, чтобы можно было подключить несколько человек к одной сессии, и передвижение кружка на одном компьютере отображалось на других.
Проблемы серверного модуля. При работе с удаленным сервером возникли проблемы с запуском серверного модуля. Сначала мы пробовали скомпилировать файлы прямо на выделенном сервере. Но для этого надо было поставить библиотеку Facebook Folly, которая либо собирается из исходников, что сделать весьма нетривиально, либо ставится на всю систему специальным скриптом — чего Егор Суворов сказал нам не делать. Зато можно было загружать любые пакеты на выделенный сервер. Поэтому мы применили статическую линковку, загрузили получившийся файл на выделенный сервер, поставили необходимые пакеты для библиотеки Facebook Folly, и уже после этого получилось запустить исполняемый файл серверного модуля на сервере. Позднее мы обернули серверную часть в docker, как нам посоветовал ментор. Теперь серверная часть собирается гораздо удобнее, чем раньше.
Микширование. Далее настало время добавлять микширование звуков. Эта часть должна запускаться на сервере как отдельный модуль. Собственно, проблем с микшированием почти не было, объединение прошло гладко. Но когда мы запустили наше приложение, были ощутимые задержки голоса при передаче его по сети, а некоторые звуки вообще пропадали и не проигрывались. Мы это исправили при поддержке ментора, и в итоге нам удалось добиться приемлемой задержки проигрывания сообщений.
Что же мы поменяли? Сначала мы стали решать проблему со звуками, которые пропадают: она возникала из-за того, что аудиофрагменты, которые пришли на микшер слишком поздно, выкидывались. Поэтому мы попытались вообще не выкидывать сообщения на микшере, а проигрывать все, что было отправлено и обработано микшером. В этом случае при запуске приложения задержки были не слишком большие, но по прошествии времени они увеличивались.
Стало понятно, что буфер аудиофрагментов, который пересылается между частями клиента, увеличивается с течением времени. То есть аудиомодуль записывал голос пользователя, далее складывал полученные аудиофрагменты в буфер, потом создавал сообщение с этим буфером и посылал его на сетевой модуль. Однако, буфер забывали чистить, поэтому его размер становился больше. Как следствие, время на пересылку сообщений увеличивалось. Мы исправили это, удаляя из буфера те сообщения, которые уже были отправлены на сетевой модуль. Потестили — теперь задержка была примерно постоянной, но хотелось, чтобы было быстрее. Тогда мы уменьшили качество записи звука в голосовых сообщениях. В итоге получился приемлемый результат: мы смогли разговаривать друг с другом через наше приложение и понимать сказанное.
Что у нас получилось
В результате получилось приложение со всем базовым функционалом, которого мы хотели добиться. Вот видео с нашей защиты. Конечно, проект не идеален. Вот несколько пунктов, которые можно изменить в нашей работе:
оптимизировать сетевую часть для уменьшения времени задержки, например, использовать альтернативный паттерн обмена сообщениями;
некоторые константы захардкожены, следовало бы предоставить пользователю возможность выбирать качество звука;
добавить комнаты для параллельных звонков;
добавить видео участников чата.
Заключение
В итоге мы пришли к выводу, что такая регулировка звука весьма удобна: чтобы сделать звук громче или тише, достаточно всего лишь пододвинуть свой кружочек к говорящему или отодвинуть соответственно. Также в одной сессии на экране пользователи могут организоваться в несколько компаний, при этом общение происходит отдельно внутри каждой группы. Пользователи могут перейти из одной компании в другую просто перетащив кружок. Это альтернативный взгляд на комнаты в приложении Zoom.
Однако, в путешествии важен не только пункт назначения, но и путь к нему. Пока мы реализовывали проект, мы прошли через многое:
работали в команде;
проходили code-review;
практиковались в использовании git;
изучили стек новых технологий: Qt, ZMQ, многопоточность, асинхронность, и так далее.
Это был классный опыт разработки своего приложения в команде. Спасибо Вышке, организаторам, нашему ментору Всеволоду Опарину и всем причастным!
Репозиторий проекта на GitHub
Другие материалы из нашего блога о проектах студентов младших курсов: