
Привет, Хабр!
В прошлой статье я делился опытом создания портативной мини-акустики с передачей аудио по Wi-Fi вместо Bluetooth. В этой — представляю её более мощную версию. Мы напечатаем корпус, усовершенствуем скрипты, разработаем фирменное приложение для Hi-Fi трансляции звука и добавим эквалайзер в систему.
❯ Вместо начала
«Поигравшись» со своим предыдущим проектом, я был настолько воодушевлен, что мне захотелось сделать что-то большее как в аппаратном плане, так и в программном. В итоге было решено собрать более мощную портативную Wi-Fi акустику с интересным дизайном и отличными характеристиками. Для передачи аудио мы будем использовать те же стандарты (технологии), что были описаны в предыдущей статье, но с некоторыми дополнениями. В результате обдумывания будущего DIY изделия, у меня сформировалось следующее подобие технического задания:
Реализация акустики со следующими параметрами:
Мощность акустической системы: 50 Вт на канал;
Тип акустического оформления: пассивный излучатель;
Диапазон воспроизводимых частот (минимум): 35-18 000 Гц;
Тип применяемых динамических головок: широкополосные, 100 мм;
Физические элементы управления: копка со светодиодным индикатором без фиксации;
Питание: питание акустики выполнить с помощью встроенного аккумулятора 21 В (Li-ion 5S), зарядку и питание обеспечить с помощью разъема USB Type-C с технологией Power Delivery (PD) 120W 20V;
Клиентское программное обеспечение: для трансляции аудио разработать мобильное приложение с элементами управления параметрами акустической системы.
Что касается физических элементов управления на акустической системе — я сторонник минимализма и искренне не понимаю, зачем в наш современный цифровой век производители портативной акустики зачастую превращают панель в пульт управления атомной станции, добавляя различные крутилки и кнопочки. Ведь всё управление можно реализовать в мобильном приложении, ведь более рационально выполнять регулировку из точки прослушивания, чем каждый раз подходя к устройству.
❯ Корпус акустики
Корпус акустики проектировался с учетом своего предыдущего (студенческого) опыта проектирования и сборки акустических систем (и усилителей). Как обычно, разработка выполнялась в свободной САПР FreeCAD. Ниже представлены скриншоты разработанной модели.

По существу, корпус акустики состоит из двух блоков, скреплённых перемычками, в пространстве между которыми реализована система для размещения электроники. Дизайн корпуса выбран не случайно — это моя дань прошлому. При проектировании я вдохновлялся магнитофоном «Романтик М-309 С», с которым провёл немало времени в детстве.
С тыльной стороны размещены пассивные излучатели, которые обеспечивают дополнительную излучающую поверхность, что обеспечивает отличное качество воспроизведения низких частот. Также спроектированы боковые панели с необходимыми элементами для размещения дисплея и разъема. Для улучшения стереобазы, корпус спроектирован так, чтобы динамические головки были повернуты в стороны от центра на небольшой угол.
❯ 3D печать
Я часто слышу утверждение, что пластик — худший материал для изготовления акустики. В некоторых случаях это верно, но, как сказал бы Альф: «Вы просто не умеете их готовить». Если изготавливать корпус из пластика методом литья, то да, акустические свойства корпуса могут быть хуже, чем у деревянных. Но тут нам на помощь приходит 3D-принтер, который позволяет менять рисунок и плотность заполнения стенок корпуса — что недостижимо при литье. Работая с 3D-печатью, я давно заметил, что изменение плотности заполнения влияет на акустические свойства. Для печати моделей я применяю HIPS пластик — это идеальный вариант для печати корпусов акустических систем, благодаря своим свойствам. Ниже представлен скриншот слайсера с отображением модели корпуса. Степень заполнения стенки 65%, рисунок «сетка» и толщина слоя 0,4 мм.

Несмотря на то, что HIPS пластик менее подвержен расслоению при печати, я рекомендую выполнять печать в закрытой термокамере и после завершения печати дождаться полного остывания, прежде чем доставать модель из принтера. Я из-за своей нетерпеливости получил пару небольших трещин, которые вы можете наблюдать далее на готовом корпусе, но, к счастью, данные дефекты легко исправляются с помощью паяльника.
❯ Электроника
В конструкции нашей портативной акустики применяются относительно недорогие компоненты, иначе всякий смысл в DIY пропадает.
▨ Вычислительная платформа
«Мозг» устройства переезжает из предыдущего проекта (одноплатник Mango Pi MQ-Quad) — он выполняет роль вычислительной системы. В качестве источника аналогового аудио сигнала будет использоваться встроенный ЦАП, его характеристики меня полностью устраивают.
▨ Усилитель мощности
В качестве усилителя я выбрал компактный модуль XH-M562 на базе чипа TPA3116d2. Эта микросхема представляет собой высококачественный УМЗЧ класса D с двумя каналами по 50 Вт и поддержкой работы на несущей частоте 1,2 МГц.

Этот модуль был заказан на всем известном оранжевом маркетплейсе. После двух недель ожидания, модуль наконец-то у меня. Первое включение и тест усилителя меня очень разочаровал, качество звука было ужасное: практически полностью отсутствовали низкие частоты и выходная мощность составляла не более 20 Вт на канал, при питании 21 В. Китайские инженеры опять что-то намудрили, поэтому пришлось воспользоваться даташитом, чтобы довести усилитель до адекватных параметров. Изучая документацию на микросхему, я обратил внимание на следующий таблицы:


Сравнив данные параметры с номиналами установленных компонентов, были обнаружены следующие причины плохого качества звучания:
Входные резисторы R5 и R6 имеют номинал 1 кОм, что вносит значительные искажения в входной сигнал.
Входные конденсаторы C10, C9, C7, C6 имеют номинал 1 мкФ, хотя в датащите четко написано: «Если требуется ровный басовый отклик вплоть до 20 Гц, рекомендуемая частота среза составляет одну десятую от этого, 2 Гц.
В таблице 2 перечислены рекомендуемые конденсаторы связи по переменному току для каждого шага усиления.»Резисторы R3 и R2, которые отвечают за настройку уровня усиления, имеют номиналы 20 кОм и 100 кОм — что устанавливает режим усиления на 26 dB, но при этом, номинал входных сопротивление должен соответствовать 30 кОм, вместо установленных 1 кОм.
Решение проблемы:
Входные конденсаторы C10, C9, C7 и C6 были заменены на 10 мкФ. Согласно таблице 2 даташита, входные резисторы R5 и R6 заменены на 9 кОм. Для установки коэффициента усиления 36 дБ резисторы R3 и R2 были заменены на 47 кОм и 75 кОм соответственно.
После проделанной операции, усилитель заиграл новыми красками! Теперь я доволен качеством звучания. Видимо китайские инженеры сознательно исказили параметры усилителя, чтобы он мог выжить в неумелых руках, так как он поставляется без радиатора и конструкция платы затрудняет его установку из-за торчащих конденсаторов (С4, С5, С3, С2, С16, С17, С18, С19). В нашем режиме установка радиатора обязательна, так как при воспроизведении низких частот микросхема имеет неплохой нагрев. Для установки радиатора необходимо демонтировать конденсаторы С4, С5, С3, С2, С16, С17, С18, С19 и заменить их на электролитические буферные ёмкости с номиналом 2200 мкФ 25 В, к счастью, на плате уже для них предусмотрены контактные площадки.
▨ Система питания
Чтобы раскрыть весь потенциал усилителя, было принято решение использовать уровень питающего напряжения 21 В. Данный уровень позволяет реализовать питание акустики как от зарядного устройства с PD 120W, так и от встроенного аккумулятора.
А так как напряжение питания нашей Mango Pi MQ-Quad составляет 5 В, то необходимо реализовать и понижающий преобразователь до указанного уровня. Учитывая данные потребности, был разработан модуль управления питанием, схема которого показана ниже:

Понижение уровня напряжения для питания одноплатника реализовано на популярной микросхеме LM2595-ADJ, где выходное напряжение задается с помощью делителя R1 и R2. Управление функции включения и выключения нашей акустики также завязано на данной микросхеме, разрешающий сигнал формируется транзистором Q1. Основной сигнал включения формируется кнопкой с последующим подхватом с помощью сигнала от SBC (Mango Pi MQ-Quad). На плате реализован вход для подключения внешнего источника, который служит для зарядки встроенного аккумулятора или питания акустики. Проверка нужного уровня входящего напряжения выполняется с помощью стабилитрона D4 с напряжением пробоя 18 В. Переключение на нужный уровень напряжения при питании от USB Type-C выполняется с помощью триггера.

Плата модуля разрабатывалась в KiCad, ниже представлены некоторые изображения из проекта


Далее плата была вытравлена и собрана, ниже размещено изображение с некоторыми этапами:

Как всегда, платы изготавливались с помощью моего небольшого лазерного станка.
Что касается встроенной аккумуляторной батареи, то здесь все по классике: я использовал б/у аккумуляторы от ноутбука, которые у меня давно валялись без дела. Батарея собрана по схеме 5S с применением платы BMS.

Для контроля уровня заряда, я собрал простой индикатор на базе компаратора LM324, ниже приведена принципиальная схема индикатора:

И чтобы стало яснее, как реализована система управления питанием, ниже приведена схема подключения управляющих цепей:

Для наглядности осталось показать только схему подключения цепей питания:

Как вы можете заметить, в схеме используются синфазные дроссели L1 и L2, которые выполняют роль фильтра для подавления шума, возникающего в процессе работы платы Mango Pi MQ-Quad. Используемый усилитель очень чувствителен к шуму в питающей цепи, поэтому установка данных фильтров обязательна. Дроссели были взяты из б/у блока питания компьютера, индуктивность не замерял, поэтому и не скажу.
❯ Программная часть
В этот раз, в отличии от прошлой серии, я использовал собственную сборку операционной системы Debian 12 со своим кастомным ядром, так как производители платы Mango Pi MQ-Quad не особо заморачиваются с программной поддержкой своих плат, а единственный «живой» образ на сайте производителя оставляет желать лучшего. Также, в отличии от прошлой версии, я не стал применять дополнительный пакеты для управления GPIO, а использовал API операционной системы. Ну, что ж, приступим.
▨ Конфигурация звуковой подсистемы
Для начала нам нужно сконфигурировать звуковую подсистему. Для начала необходимо посмотреть какие звуковые устройства нам доступны, выполнив команду:
aplay -l
В результате выполнения команды, мы увидим что-то подобное:
**** List of PLAYBACK Hardware Devices ****
card 0: Codec [H616 Audio Codec], device 0: CDC PCM Codec-0 [CDC PCM Codec-0]
Subdevices: 0/1
Subdevice #0: subdevice #0
card 1: sndahub [sndahub], device 0: Media Stream sunxi-ahub-aif1-0 [Media Stream sunxi-ahub-aif1-0]
Subdevices: 1/1
Subdevice #0: subdevice #0
card 1: sndahub [sndahub], device 1: System Stream sunxi-ahub-aif2-1 [System Stream sunxi-ahub-aif2-1]
Subdevices: 1/1
Subdevice #0: subdevice #0
card 1: sndahub [sndahub], device 2: Accompany Stream sunxi-ahub-aif2-2 [Accompany Stream sunxi-ahub-aif2-2]
Subdevices: 1/1
Subdevice #0: subdevice #0
card 2: allwinnerhdmi [allwinner-hdmi], device 0: hdmi i2s-hifi-0 []
Subdevices: 1/1
Subdevice #0: subdevice #0
card 3: hificyberexsoun [hifi-cyberex-sound], device 0: sunxi-ahub-cpu-aif0-pcm5102a-hifi pcm5102a-hifi-0 []
Subdevices: 1/1
Subdevice #0: subdevice #0
В нашем случае, мы будем использовать встроенный ЦАП, который определяется как Codec [H616 Audio Codec] и имеет адрес устройства card 0. Запоминаем адрес карты и идем дальше.
Нет смысла использовать мощную акустику без подстройки АЧХ, поэтому для этих целей мы будем применять десятиполосный параметрический эквалайзер. Чтобы реализовать эту функцию в нашей аудиоподсистеме, необходимо установить дополнительные плагины с помощью команды:
sudo apt update
sudo apt install libasound2-plugin-equal alsa-tools-gui
После успешной установки, после выполнения команды:
alsamixer -D equal
вы увидите что-то подобное:

Не обращайте внимание на уровни, в вашем случае все ползунки будут выставлены на уровень 50, скриншот делал со своей рабочей системы.
Также перед дальнейшим использованием, нам необходимо настроить наше основное аудио устройство с помощью команды:
alsamixer
И сконфигурировать, как показано на скриншоте:

Далее добавим наш эквалайзер в основную конфигурацию аудиоподсистемы:
sudo nano /etc/asound.conf
Добавив следующее содержимое:
ctl.equal {
type equal
}
pcm.plugequal {
type equal
slave.pcm "plug:dmixer"
}
pcm.equal {
type plug
slave.pcm plugequal
}
pcm.dmixer {
type dmix
ipc_key 1024
slave {
pcm "hw:0"
period_time 0
period_size 1920
buffer_size 19200
rate 48000
format S32_LE
}
}
pcm.!default {
type plug
slave.pcm "equal"
}
ctl.!default {
type hw
card 0
}
Теперь мы сможем перенаправлять аудиопоток через эквалайзер.
▨ Установка рендерера
Как и в прошлой статье, для приема аудиопотока мы воспользуется DLNA рендерером Gmrender-Resurrect. Ниже представлены шаги по установке.
Установка дополнительных зависимостей, необходимых для компиляции:
sudo apt-get install build-essential autoconf automake libtool pkg-config
Установка дополнительных библиотек, которые использует рендерер для своей работы:
sudo apt-get update
sudo apt-get install libupnp-dev libgstreamer1.0-dev \
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \
gstreamer1.0-libav
Дополнительные зависимости для работы с нашей аудио подсистемы:
sudo apt-get install gstreamer1.0-alsa
Клонируем репозиторий:
git clone https://github.com/hzeller/gmrender-resurrect.git
Переходим в папку репозитория, выполняем конфигурацию и сборку пакета:
cd gmrender-resurrect
./autogen.sh
./configure
make
Установка:
sudo make install
Теперь нам нужно создать сервис для автозапуска рендерера в нашей системе:
sudo nano /etc/systemd/system/gmediarender.service
И добавим следующее содержимое:
[Unit]
Description=DLNA Renderer
After=network.target
After=sound.target
[Service]
User=root
Group=root
ExecStartPre=/bin/sleep 30
ExecStart=/usr/local/bin/gmediarender --gstout-audiosink=alsasink --gstout-audiodevice=equal --friendly-name "CYBEREX SOUND" interface=wlan0
Nice=-20
Restart=on-failure
[Install]
WantedBy=multi-user.target
--gstout-audiosink=alsasink --gstout-audiodevice=equal
- данные аргументы устанавливают в качестве устройства воспроизведения наш эквалайзер.
Сохраняем файл и добавляем в автозагрузку:
systemctl enable gmediarender.service
▨ Алгоритм включения
Как вы могли видеть ранее на схеме подключения управляющих цепей, на модуль управления питанием приходят два сигнала ON SIGN BUTTON и ON CONFIRM SBC — именно они отвечают за запуск системы и подачу питания. Первый сигнал приходит с кнопки включения, а второй фиксирует запуск и формируется GPIO одноплатником Mango Pi MQ-Quad (в случае корректного запуска системы). Ниже представлен код Python скрипта, который формирует разрешающий сигнал:
Скрипт формирования разрешающего сигнала [hello_display.py]
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from luma.oled.device import ssd1306
from PIL import ImageFont
import time
# Настройка I2C интерфейса
serial = i2c(port=0, address=0x3C) # 0x3C - адрес для SSD1306
device = ssd1306(serial, width=64, height=48)
# Загрузка шрифта с поддержкой кириллицы
font_path = "fonts/UbuntuMono-R.ttf" # Шрифт
font = ImageFont.truetype(font_path, 31, encoding='UTF-8') # размер шрифта
font_b = ImageFont.truetype(font_path, 16, encoding='UTF-8') # размер шрифта
GPIO_PIN_POWER = 234 # PH10
# Экспорт GPIO для PH10 (выход)
with open("/sys/class/gpio/export", "w") as f:
f.write(str(GPIO_PIN_POWER))
# Настройка пина как выход
with open(f"/sys/class/gpio/gpio{GPIO_PIN_POWER}/direction", "w") as f:
f.write("out")
# Подаем разрешение на питание
with open(f"/sys/class/gpio/gpio{GPIO_PIN_POWER}/value", "w") as f:
f.write("1")
# Текст и начальная позиция бегущей строки
title_d = ""
artist_d = "ЗАГРУЗКА..."
album_d = ""
track_time = ""
def display_print():
start_pix_t = 64
start_pix_ar = 1
start_pix_al = 64
while True:
if True:
title_width = len(title_d) * 6 # Ожидаемая ширина текста (по 6 пикселей на символ)
artist_width = len(artist_d) * 6
album_width = len(album_d) * 6
max_width = max(title_width, artist_width, album_width)
with canvas(device) as draw:
#draw.text((start_pix_t, 1), title_d, fill="white", font=font)
draw.text((start_pix_ar, 4), artist_d, fill="white", font=font)
#draw.text((start_pix_al, 24), album_d, fill="white", font=font)
#draw.text((15, 36), track_time, fill="white")
# Сдвиг текста влево
if title_width > 64:
start_pix_t -= 1
else:
start_pix_t = 1
if artist_width > 64:
start_pix_ar -= 1
else:
start_pix_ar = 1
if album_width > 64:
start_pix_al -= 1
else:
start_pix_al = 1
# Если текст полностью вышел за экран, вернем его в начальную позицию
if start_pix_t < -max_width:
start_pix_t = 64
if start_pix_ar < -max_width:
start_pix_ar = 64
if start_pix_al < -max_width:
start_pix_al = 64
time.sleep(0.05)
display_print()
Данный скрипт также отображает на дисплее бегущую строку с надписью «ЗАГРУЗКА», появление которой сигнализирует о том, что кнопку можно отпустить.
Для активации скрипта при запуске системы, необходимо создать сервис:
sudo nano /etc/systemd/system/hello_display.service
Со следующим содержанием:
[Unit]
Description=OLED Logon Display Service
#After=power_on.service
[Service]
User=root
Group=root
ExecStart=/root/myvenv/bin/python3 /home/scripts/hello_display.py
WorkingDirectory=/home/scripts
Environment="PATH=/root/myvenv/bin:/usr/bin:/bin"
StandardOutput=inherit
StandardError=inherit
[Install]
WantedBy=multi-user.target
Скрипт работает с виртуальным окружением, как его активировать смотрите в предыдущей статье, там же найдете информацию касательно дисплея. И для активации автозагрузки, выполним следующую команду:
systemctl enable hello_display.service
▨ Дисплей и функция выключения
Как и раннее, качестве дисплея используется OLED модуль SSD1306 с разрешением 64 х 48, а вся логику управления дисплеем реализована в небольшом Python скрипте с дополнением функции выключения системы по нажатию кнопки питания:
Код скрипта [media_info_disp.py]
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from luma.oled.device import ssd1306
from PIL import ImageFont
from threading import Thread
import time
import datetime
import math
import requests
from xml.etree import ElementTree as ET
import netifaces
import subprocess
# Настройка I2C интерфейса
serial = i2c(port=0, address=0x3C) # 0x3C - адрес для SSD1306
device = ssd1306(serial, width=64, height=48)
# Загрузка шрифта с поддержкой кириллицы
font_path = "fonts/UbuntuMono-R.ttf" # Шрифт
font = ImageFont.truetype(font_path, 12, encoding='UTF-8') # размер шрифта
font_b = ImageFont.truetype(font_path, 16, encoding='UTF-8') # размер шрифта
font_b2 = ImageFont.truetype(font_path, 36, encoding='UTF-8') # размер шрифта
# Указываем номер GPIO
GPIO_PIN_AMP = 272 # PI16 включение усилителя
GPIO_PIN_BUTTON = 271 # PI15 мониторинг кнопки
# Отмена экспорта GPIO
#try:
# with open("/sys/class/gpio/unexport", "w") as f:
# f.write(str(GPIO_PIN_AMP))
# with open("/sys/class/gpio/unexport", "w") as f:
# f.write(str(GPIO_PIN_BUTTON))
#except ValueError:
# print(f"Какая-то ошибка.")
# Экспорт GPIO
with open("/sys/class/gpio/export", "w") as f:
f.write(str(GPIO_PIN_AMP))
# Экспорт GPIO для PI15 (вход)
with open("/sys/class/gpio/export", "w") as f:
f.write(str(GPIO_PIN_BUTTON))
# Настройка пина как выход
with open(f"/sys/class/gpio/gpio{GPIO_PIN_AMP}/direction", "w") as f:
f.write("out")
# Настройка PI15 как вход
with open(f"/sys/class/gpio/gpio{GPIO_PIN_BUTTON}/direction", "w") as f:
f.write("in")
# Интерфейс
interface_name = "wlan0"
# Получаем информацию об интерфейсе
try:
addresses = netifaces.ifaddresses(interface_name)
if netifaces.AF_INET in addresses:
ip_address = addresses[netifaces.AF_INET][0]['addr']
print(f"IP-адрес на интерфейсе {interface_name}: {ip_address}")
else:
print(f"Интерфейс {interface_name} не имеет IPv4-адреса.")
except ValueError:
print(f"Интерфейс {interface_name} не найден.")
#ip_address = "192.168.1.205"
service_url = f"http://{ip_address}:49494/upnp/control/rendertransport1"
# Текст и начальная позиция бегущей строки
title_d = ""
artist_d = "ГОТОВ К ПОДКЛЮЧЕНИЮ"
album_d = ""
track_time = ""
current_time_arh = ""
counter_end = 0
en = False
power_off = False
def set_poff_bool(bools):
global power_off
power_off = bools
def read_poff_bool():
global power_off
return power_off
def set_bool(bools):
global en
en = bools
# print(en)
def read_bool():
global en
return en
# Делаем часики
def draw_clock(draw, now):
center_x = 32
center_y = 24
radius = 25
# Делаем рамку с закруглением
draw.rectangle(device.bounding_box, outline="black", fill="black")
draw.rounded_rectangle(device.bounding_box, radius=8, outline="white", fill="black")
# Часовая
hour_angle = 2 * math.pi * (now.hour % 12 + now.minute / 60) / 12
hour_x = center_x + int(radius * 0.5 * math.sin(hour_angle))
hour_y = center_y - int(radius * 0.5 * math.cos(hour_angle))
draw.line((center_x, center_y, hour_x, hour_y), fill="white")
# Минутная
minute_angle = 2 * math.pi * now.minute / 60
minute_x = center_x + int(radius * 0.7 * math.sin(minute_angle))
minute_y = center_y - int(radius * 0.7 * math.cos(minute_angle))
draw.line((center_x, center_y, minute_x, minute_y), fill="white")
# Секундная
second_angle = 2 * math.pi * now.second / 60
second_x = center_x + int(radius * 0.9 * math.sin(second_angle))
second_y = center_y - int(radius * 0.9 * math.cos(second_angle))
draw.line((center_x, center_y, second_x, second_y), fill="white")
# Рисуем круг циферблата
# draw.ellipse((center_x - radius, center_y - radius, center_x + radius, center_y + radius), outline="white")
# Делаем рамку с закруглением
#draw.rounded_rectangle(device.bounding_box, radius=5, outline="white", fill="black")
def display_print():
start_pix_t = 64
start_pix_ar = 64
start_pix_al = 64
while True:
if read_bool():
title_width = len(title_d) * 6 # Ожидаемая ширина текста (по 6 пикселей на символ)
artist_width = len(artist_d) * 6
album_width = len(album_d) * 6
max_width = max(title_width, artist_width, album_width)
with canvas(device) as draw:
if title_d == "swyh-rs":
# draw.text((start_pix_t, 1), title_d, fill="white")
draw.text((1, 12), "ПК АУДИО", fill="white", font=font_b)
#draw.text((start_pix_al, 24), album_d, fill="white")
draw.text((15, 36), track_time, fill="white")
else:
# Прокрутка текста
# draw.rectangle(device.bounding_box, outline="white", fill="black")
draw.text((start_pix_t, 1), title_d, fill="white", font=font)
draw.text((start_pix_ar, 12), artist_d, fill="white", font=font)
draw.text((start_pix_al, 24), album_d, fill="white", font=font)
draw.text((15, 36), track_time, fill="white")
# Сдвиг текста влево
if title_width > 64:
start_pix_t -= 1
else:
start_pix_t = 1
if artist_width > 64:
start_pix_ar -= 1
else:
start_pix_ar = 1
if album_width > 64:
start_pix_al -= 1
else:
start_pix_al = 1
# Если текст полностью вышел за экран, вернем его в начальную позицию
if start_pix_t < -max_width:
start_pix_t = 64
if start_pix_ar < -max_width:
start_pix_ar = 64
if start_pix_al < -max_width:
start_pix_al = 64
time.sleep(0.05)
# Получение данных с рендеринга
# Функция для разбора CurrentURIMetaData
def parse_metadata(metadata):
global title_d
global artist_d
global album_d
if metadata:
# Парсим метаданные как XML
root = ET.fromstring(metadata)
namespace = {'didl': 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/',
'dc': 'http://purl.org/dc/elements/1.1/',
'upnp': 'urn:schemas-upnp-org:metadata-1-0/upnp/'}
# Извлекаем информацию о треке
title = root.find('.//dc:title', namespace)
artist = root.find('.//upnp:artist', namespace)
album = root.find('.//upnp:album', namespace)
album_art = root.find('.//upnp:albumArtURI', namespace)
title_d = title.text if title is not None else "Unknown"
artist_d = artist.text if artist is not None else "Unknown"
album_d = album.text if album is not None else "Unknown"
#print("Track Information:")
#print(f" Title: {title.text if title is not None else 'Unknown'}")
#print(f" Artist: {artist.text if artist is not None else 'Unknown'}")
#print(f" Album: {album.text if album is not None else 'Unknown'}")
#print(f" Album Art URI: {album_art.text if album_art is not None else 'None'}")
#else:
#print("No metadata available.")
# Функция для получения временных меток
def get_position_info():
global track_time
global current_time_arh
global title_d
global counter_end
# Заголовки и тело SOAP-запроса
headers = {
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": '"urn:schemas-upnp-org:service:AVTransport:1#GetPositionInfo"',
}
body = """<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetPositionInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:GetPositionInfo>
</s:Body>
</s:Envelope>"""
# Отправляем запрос
response = requests.post(service_url, headers=headers, data=body)
# Разбираем результат
if response.status_code == 200:
xml_response = ET.fromstring(response.content)
rel_time = xml_response.find('.//RelTime').text # Текущее время
track_duration = xml_response.find('.//TrackDuration').text # Общая длительность трека
track_time = rel_time
if current_time_arh == rel_time:
if counter_end > 10:
set_bool(False)
#wiringpi.digitalWrite(PI16, 0) # отклюающий сигнал для усилителя
with open(f"/sys/class/gpio/gpio{GPIO_PIN_AMP}/value", "w") as f:
f.write("0")
counter_end += 1
else:
current_time_arh = rel_time
set_bool(True)
#wiringpi.digitalWrite(PI16, 1) # разрешающий сигнал для усилителя
with open(f"/sys/class/gpio/gpio{GPIO_PIN_AMP}/value", "w") as f:
f.write("1")
counter_end = 0
#print("Playback Position Info:")
#print(f" Current Time: {rel_time}")
#print(f" Track Duration: {track_duration}")
#else:
#print(f"Error getting position info: {response.status_code}, {response.text}")
def read_data_from_renderer():
# Функция для получения информации о воспроизводимом медиа с рендерера.
# Заголовки для GetMediaInfo
headers = {
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": '"urn:schemas-upnp-org:service:AVTransport:1#GetMediaInfo"',
}
# Тело SOAP-запроса для GetMediaInfo
body = """<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetMediaInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:GetMediaInfo>
</s:Body>
</s:Envelope>"""
# Отправляем запрос на GetMediaInfo
response = requests.post(service_url, headers=headers, data=body)
# Проверяем ответ
if response.status_code == 200:
# Парсим XML ответа
xml_response = ET.fromstring(response.content)
# Извлекаем данные о медиа
nr_tracks = xml_response.find('.//NrTracks').text
current_uri = xml_response.find('.//CurrentURI').text
metadata = xml_response.find('.//CurrentURIMetaData').text
# print("Basic Media Info:")
# print(f" Number of Tracks: {nr_tracks}")
# print(f" Current URI: {current_uri}")
# Разбираем метаданные
parse_metadata(metadata)
# Получаем временные метки
get_position_info()
#else:
#print(f"Error: {response.status_code}, {response.text}")
# Функиця для периодического вызова
def periodic_task():
while True:
read_data_from_renderer()
time.sleep(1) # Задержка в 1 секунду
def periodic_task_clock():
while True:
if not read_bool() and not read_poff_bool():
now = datetime.datetime.now()
with canvas(device) as draw:
draw.rectangle(device.bounding_box, outline="white", fill="black")
draw_clock(draw, now)
time.sleep(1)
def read_power_button_status():
while True:
# Чтение состояния PI15
with open(f"/sys/class/gpio/gpio{GPIO_PIN_BUTTON}/value", "r") as f:
ph10_state = f.read().strip()
if ph10_state == '1':
set_poff_bool(True)
print(f"Выключение.")
with canvas(device) as draw:
draw.text((15, 12), "ВЫКЛ", fill="white", font=font_b)
# Асинхронное выполнение команды shutdown now
subprocess.Popen(["/sbin/shutdown", "now"])
time.sleep(1)
# Запуск потока для отображения текста
t1 = Thread(target=display_print, name='t1')
t1.start()
# Запуск переодического опрома медиа
t2 = Thread(target=periodic_task, name='t2')
t2.start()
# Запуск переодического круглые часы
t3 = Thread(target=periodic_task_clock, name='t3')
t3.start()
# Запуск опрос кнопки
t4 = Thread(target=read_power_button_status, name='t4')
t4.start()
По аналогии, создаем сервис для автозапуск скрипта:
sudo nano /etc/systemd/system/oled_display.service
Добавляем следующее содержимое:
[Unit]
Description=OLED Display Service
After=network.target
After=gmediarender.service
[Service]
User=root
Group=root
ExecStartPre=systemctl stop hello_display.service
ExecStart=/root/myvenv/bin/python3 /home/scripts/media_info_disp.py
WorkingDirectory=/home/scripts
Environment="PATH=/root/myvenv/bin:/usr/bin:/bin"
StandardOutput=inherit
StandardError=inherit
Restart=always
[Install]
WantedBy=multi-user.target
И для активации автозапуска, выполним следующую команду:
systemctl enable oled_display.service
Существует ещё одна особенность: при завершении работы системы пины не сбрасывают свой статус, а нам это критически необходимо для сброса разрешающего сигнала, тем самым отключая питание системы. Для решения этой проблемы создадим новый сервис, который будет запускаться после выполнения команд, завершающих работу системы:
sudo nano /etc/systemd/system/gpio-shutdown.service
И добавим следующее содержание:
[Unit]
Description=Reset GPIO pin PH10 on shutdown
DefaultDependencies=no
Before=shutdown.target reboot.target halt.target
[Service]
User=root
Group=root
Type=oneshot
ExecStartPre=/bin/sleep 15
ExecStart=/bin/bash -c "echo 0 > /sys/class/gpio/gpio234/value"
RemainAfterExit=yes
[Install]
WantedBy=halt.target reboot.target shutdown.target
И активируем автозапуск скрипта:
systemctl enable gpio-shutdown.service
▨ API управления эквалайзером
Управление эквалайзером через командную строку это конечно по гиковски, но хотелось бы упростить задачу настройки эквалайзера для рядового пользователя, поэтому я решил перенести функцию регулировки эквалайзера в мобильное приложения. Для осуществления задуманного, нам необходимо реализовать API, сделаем это с помощью следующего Python скрипта:
Код API эквалайзера [eq.py]
import json
import subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
class EQRequestHandler(BaseHTTPRequestHandler):
def _set_headers(self, status_code=200):
self.send_response(status_code)
self.send_header('Content-type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
def do_OPTIONS(self):
self._set_headers(204)
def do_GET(self):
if self.path == '/eq':
self._set_headers()
eq_values = self.get_equalizer_values()
self.wfile.write(json.dumps(eq_values).encode())
else:
self._set_headers(404)
self.wfile.write(json.dumps({"error": "Not found"}).encode())
def do_POST(self):
if self.path == '/eq':
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
try:
data = json.loads(post_data)
results = {}
for band, values in data.items():
if isinstance(values, list):
success = self.set_equalizer_value(band, *values)
else:
success = self.set_equalizer_value(band, values)
results[band] = "success" if success else "failed"
self._set_headers()
self.wfile.write(json.dumps(results).encode())
except json.JSONDecodeError:
self._set_headers(400)
self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
else:
self._set_headers(404)
self.wfile.write(json.dumps({"error": "Not found"}).encode())
def get_equalizer_values(self):
"""Получает текущие значения эквалайзера"""
process = subprocess.Popen(
['amixer', '-D', 'equal', 'contents'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
stdout, stderr = process.communicate()
if stderr:
return {"error": stderr.decode()}
equalizer_data = stdout.decode().split('\n')
equalizer_values = {}
current_band = None
for line in equalizer_data:
line = line.strip()
if line.startswith("numid=") and "name=" in line:
name_start = line.find("name='") + 6
name_end = line.find("'", name_start)
current_band = line[name_start:name_end]
elif line.startswith(": values=") and current_band:
values_start = line.find("=") + 1
values = line[values_start:]
equalizer_values[current_band] = values.split(',')
return equalizer_values
def set_equalizer_value(self, band_name, left_value, right_value=None):
"""Устанавливает значение для полосы эквалайзера"""
if right_value is None:
right_value = left_value
cmd = [
'amixer', '-D', 'equal',
'cset', f"name='{band_name}'", f"{left_value},{right_value}"
]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
def run(server_class=HTTPServer, handler_class=EQRequestHandler, port=8090):
server_address = ('', port)
httpd = server_class(server_address, handler_class)
print(f'Starting httpd server on port {port}...')
httpd.serve_forever()
if __name__ == "__main__":
run()
Данный скрипт выполняет функцию выгрузки конфигурации эквалайзера в JSON формате по запросу приложения и установки нового уровня полосы. По сути, мы просто парсим командную строку с выводом эквалайзера для чтения текущих уровней и устанавливаем новые с помощью модуля subprocess.
Как и предыдущие скрипты, скрипт API эквалайзера добавим в автозагрузку с помощью сервиса:
sudo nano /etc/systemd/system/eq_web.service
Со следующим содержимым:
[Unit]
Description=EQ Web Service
After=network.target
After=gmediarender.service
[Service]
User=root
Group=root
ExecStartPre=systemctl stop hello_display.service
ExecStart=/root/myvenv/bin/python3 /home/scripts/eq.py
WorkingDirectory=/home/scripts
Environment="PATH=/root/myvenv/bin:/usr/bin:/bin"
StandardOutput=inherit
StandardError=inherit
Restart=always
[Install]
WantedBy=multi-user.target
И активируем автозапуск:
systemctl enable eq_web.service
▨ Мобильное приложение
Здесь я изобретаю свой велосипед разработал мобильное приложение для трансляции аудиопотока в Hi-Fi качестве с мобильного устройства, дополнительно реализовав функцию управления эквалайзером, используя наш API, который мы реализовали ранее. Ниже представлены скриншоты экранов приложения:

В приложении реализован запуск/остановка трансляции системного звука, настройка эквалайзера, лаунчер приложений популярных стриминговых сервисов и, чтобы было красивее, добавил индикатор спектра. Регулировка громкости акустики выполняется с помощью кнопок громкости смартфона, во время работы главного экрана приложения. Трансляция аудиопотока выполняется со следующими характеристиками:
Контейнер WAV;
Частота дискретизации: 48 кГц;
Глубина: 32 бит;
Кодек: PCM.
Акустика автоматически определяется в сети с помощью UPnP протокола в процессе запуска приложения.
❯ Сборка акустической системы
Пожалуй, это один из самых приятных процессов, словно собирать конструктор в детстве.
▨ Всё акустическое
После теста нескольких динамиков одного форм-фактора и разных производителей, я остановился на следующей модели:

Производитель заявляет:
Акустика серии ACV PD – это широкополосные динамики по бюджетной стоимости, которые отлично подходят для штатной установки. Имеют достаточно высокую номинальную мощность для такого вида систем и чувствительность 88 дБ.
Динамики исполнены в стальных штампованных корзинах, оснащены бумажными диффузорами с резиновыми подвесами, воспроизводят диапазон 70 Гц-20 кГц.
Ниже приведены технические характеристики динамика:

Несмотря на то, что производителем заявлен диапазон воспроизводимых частот 70 Гц–20 кГц, данный динамик неплохо справляется с воспроизведением НЧ-диапазона, начиная с 28 Гц после настройки эквалайзера. Динамик имеет хороший подвес и больший ход по сравнению с аналогами в том же ценовом сегменте.
Поскольку в моей конструкции используется акустическое оформление типа ПИ (пассивный излучатель), необходимо обеспечить максимальную герметичность корпуса колонки для качественного воспроизведения низких частот. Для герметизации динамиков я напечатал уплотнительные кольца из TPU-пластика, который отлично справляется с этой задачей.

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

Пассивные излучатели были заказаны в Китае. Пока они шли, я решил провести эксперимент: а что, если напечатать их самостоятельно из TPU-пластика? В результате нескольких итераций и модернизаций мне удалось создать оптимальную конструкцию мембраны.

После установки мембран, задняя часть акустики выглядела следующим образом:

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

▨ Установка электроники
Платы электроники и аккумуляторная батарея устанавливается в шасси, которое спроектировано особым образом для удобного расположения компонентов и проводки:

Блок АКБ также приобрел свой корпус, который был напечатан на 3D принтере. Для возможности отключения аккумулятора используется разъем XT-60.
Индикатор заряда и OLED дисплей устанавливается на переднюю панель:

В итоге передняя панель выглядит следующим образом:

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

▨ Итоговая конструкция
После завершения процесса сборки, вы можете видеть следующий результат:


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

❯ Итоги
Изначально я испытывал скепсис относительно воспроизведения низкочастотного диапазона, но результат развеял все сомнения. Звучание акустической системы оказалось достаточно приятным и вызывает только положительные эмоции — низкие частоты мягкие и естественные. Основную роль в этом, конечно, играет система доставки аудиоконтента и качество усилителя. Ниже приведены демонстрационные видео работы системы.
Видео демонстрации
На ближайшие недели данная акустика стала любимым средством для воспроизведения стримингового контента в Hi-Fi качестве. И подозреваю, что соседи тоже довольны.
Если у вас есть что добавить, то добро пожаловать в комментарии! Всем спасибо за уделенное время!
Ссылки к статье:
Исходники проекта [GitHub];
Мобильное приложение [GooglePlay].
Данная статья сгенерирована человеком.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩

Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.