Add measured room EQ for Mini 2
This commit is contained in:
@@ -7,3 +7,7 @@
|
|||||||
/data/
|
/data/
|
||||||
/dist/
|
/dist/
|
||||||
/wake-helper/target/
|
/wake-helper/target/
|
||||||
|
/room-eq/build/
|
||||||
|
/room-eq/results/
|
||||||
|
/room-eq/__pycache__/
|
||||||
|
/room-eq/.venv/
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ Caddy проксирует голосовое соединение и `/healthz`
|
|||||||
|
|
||||||
Музыку на колонке воспроизводит официальный Spotify Soloist. На Mini 2 он запускается с отдельным ARM64 runtime и PulseAudio. Музыка и ответы идут через разные входы микшера. Во время запроса музыкальный вход затихает, а после запроса его громкость восстанавливается. Станция буферизует начало команды, пока устанавливает соединение.
|
Музыку на колонке воспроизводит официальный Spotify Soloist. На Mini 2 он запускается с отдельным ARM64 runtime и PulseAudio. Музыка и ответы идут через разные входы микшера. Во время запроса музыкальный вход затихает, а после запроса его громкость восстанавливается. Станция буферизует начало команды, пока устанавливает соединение.
|
||||||
|
|
||||||
|
[Калибровка Room EQ](room-eq/README.md) измеряет акустику возле Mini 2 или в зоне прослушивания и позволяет сравнить коррекцию с исходным звучанием Spotify.
|
||||||
|
|
||||||
Для поиска по названию создай Spotify Web API приложение с Redirect URI `https://miniapp.pepega.systems/api/spotify/callback`, задай его Client ID в `SPOTIFY_CLIENT_ID` на сервере и подключи аккаунт в пульте. Soloist отдельно требует свой ключ и первый выбор устройства «Pepega Mini 2» в приложении Spotify. Сессия Connect сохраняется на колонке.
|
Для поиска по названию создай Spotify Web API приложение с Redirect URI `https://miniapp.pepega.systems/api/spotify/callback`, задай его Client ID в `SPOTIFY_CLIENT_ID` на сервере и подключи аккаунт в пульте. Soloist отдельно требует свой ключ и первый выбор устройства «Pepega Mini 2» в приложении Spotify. Сессия Connect сохраняется на колонке.
|
||||||
|
|
||||||
Клод вызывает `spotify_play` для трека, исполнителя, альбома или плейлиста. Поддерживаются пауза, продолжение, следующий и предыдущий трек и установка громкости. Короткие команды «пауза», «выключи музыку» и «громкость 50» выполняются без озвучивания подтверждения. Для актуальных сведений и проверки неизвестных фактов Клод может вызвать [серверный веб-поиск OpenRouter](https://openrouter.ai/docs/guides/features/server-tools/web-search).
|
Клод вызывает `spotify_play` для трека, исполнителя, альбома или плейлиста. Поддерживаются пауза, продолжение, следующий и предыдущий трек и установка громкости. Короткие команды «пауза», «выключи музыку» и «громкость 50» выполняются без озвучивания подтверждения. Для актуальных сведений и проверки неизвестных фактов Клод может вызвать [серверный веб-поиск OpenRouter](https://openrouter.ai/docs/guides/features/server-tools/web-search).
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ alice.pepega.systems {
|
|||||||
}
|
}
|
||||||
|
|
||||||
miniapp.pepega.systems {
|
miniapp.pepega.systems {
|
||||||
@miniapp path / /style.css /app.js /api/admin /api/admin/* /api/spotify/start /api/spotify/callback
|
@miniapp path / /style.css /app.js /room-mic /room-mic.js /room-mic-worklet.js /api/admin /api/admin/* /api/spotify/start /api/spotify/callback
|
||||||
handle @miniapp {
|
handle @miniapp {
|
||||||
reverse_proxy alice-backend:3000
|
reverse_proxy alice-backend:3000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Room EQ для Mini 2
|
||||||
|
|
||||||
|
Калибровка воспроизводит короткий свип через Mini 2. Анализ выравнивает запись по двум меткам, проверяет шум, перегрузку и рассинхронизацию, затем строит сглаженную относительную АЧХ. Фильтры работают на музыкальном входе PulseAudio. На ответы ассистента коррекция не влияет. Тестовый сигнал ограничен −16 dBFS и запускается при громкости станции от 10 до 65% и поставленном на паузу Spotify.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
Запускай команды из корня репозитория. Для автоматической записи на Mac нужны Swift из Xcode Command Line Tools и разрешение macOS на микрофон. Чтобы использовать USB-микрофон, выбери его как вход в настройках звука macOS. У `calibrate.py` можно передать `--host`, `--port`, `--identity` перед командой.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m venv room-eq/.venv
|
||||||
|
room-eq/.venv/bin/pip install -r room-eq/requirements.txt
|
||||||
|
mkdir -p room-eq/results
|
||||||
|
# Для первой установки на колонку нужны Zig, curl, ar и tar.
|
||||||
|
room-eq/build.sh
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Встроенные микрофоны
|
||||||
|
|
||||||
|
Сними аппаратное отключение микрофона. На время записи голосовой клиент уступит доступ к ALSA и затем запустится снова.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py capture room-eq/results/placement.raw
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py fit --internal room-eq/results/placement.raw --output room-eq/results/placement
|
||||||
|
```
|
||||||
|
|
||||||
|
Чтобы измерить именно эффект установки у стены или в углу, сначала запиши колонку на открытом месте, сохранив громкость и ориентацию, а затем там, где она обычно стоит. Передай открытую запись через `--reference room-eq/results/open.raw` в конец команды `fit`. Четыре встроенных микрофона видят прежде всего ближнее поле. Этот режим ищет широкие повторяющиеся пики в районе 150–500 Гц и ограничивает срез до 3 дБ.
|
||||||
|
|
||||||
|
## Внешний микрофон
|
||||||
|
|
||||||
|
Расположи микрофон на высоте ушей. Начни с обычной точки прослушивания и добавь ещё две точки на расстоянии примерно 30–50 см или в других местах, где обычно сидишь. Сохраняй громкость, ориентацию и выбранный вход. На время каждого замера сохраняй тишину.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py record-mac room-eq/results/seat-1.wav
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py record-mac room-eq/results/seat-2.wav
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py record-mac room-eq/results/seat-3.wav
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py fit room-eq/results/seat-[123].wav --output room-eq/results/room
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py plot room-eq/results/room.csv room-eq/results/room.png
|
||||||
|
```
|
||||||
|
|
||||||
|
На телефоне открой [страницу записи](https://miniapp.pepega.systems/room-mic) в обычном браузере, начни запись и сразу запусти `calibrate.py play` на компьютере, подключённом к станции. Перенеси скачанные WAV на компьютер. Запись обрабатывается на устройстве. Если браузер сообщает об активной автоматической обработке микрофона, используй Mac или USB-микрофон. Кривые нормируются по диапазону 750–1900 Гц, поэтому показывают относительный баланс, без абсолютного SPL и поправки на АЧХ конкретного микрофона.
|
||||||
|
|
||||||
|
Внешний режим ищет пики примерно от 125 до 920 Гц. Медиана показывает общий баланс, а разброс и нижний квартиль помогают отсеять пики, характерные для одной точки. Цель имеет лёгкий наклон в сторону тёплого звучания. Провалы не поднимаются. Суммарный срез ограничен 6 дБ, а по одной записи применяются более строгие ограничения. Если безопасных фильтров нет, команда оставит список пустым.
|
||||||
|
|
||||||
|
## Прослушивание и повторный замер
|
||||||
|
|
||||||
|
При наличии фильтров загрузи профиль. По умолчанию музыкальный тракт остаётся в исходном режиме. Включи знакомую музыку и запусти слепое сравнение. Команда `ab` подбирает приближённо равный уровень и при выходе возвращает исходный тракт.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py apply room-eq/results/room.json
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py ab
|
||||||
|
```
|
||||||
|
|
||||||
|
Снова поставь Spotify на паузу и запиши исходные точки в том же порядке с `record-mac … --preset corrected`. Для телефона запусти `play corrected`, для встроенного микрофона — `capture … --preset corrected`. Проверь, где изменилось звучание, а не только среднее отклонение по комнате.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py verify room-eq/results/room.csv room-eq/results/after-[123].wav --output room-eq/results/check
|
||||||
|
room-eq/.venv/bin/python room-eq/calibrate.py plot room-eq/results/check.csv room-eq/results/check.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Включить сохранённую коррекцию можно командой `eq`, вернуть исходный тракт — `off`, удалить профиль — `disable`. Скрипт Spotify восстанавливает выбранный режим при запуске PulseAudio. После перестановки колонки имеет смысл снять новый замер.
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# The module must match the private PulseAudio runtime on the Mini 2.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
output=${1:-./build}
|
||||||
|
mkdir -p "$output"
|
||||||
|
output=$(cd "$output" && pwd)
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -r "$tmp"' EXIT
|
||||||
|
mirror=https://deb.debian.org/debian/pool/main
|
||||||
|
curl -fsSL "$mirror/p/pulseaudio/pulseaudio_17.0+dfsg1-2+b1_arm64.deb" -o "$tmp/pulse.deb"
|
||||||
|
curl -fsSL "$mirror/l/ladspa-sdk/ladspa-sdk_1.17-1+b1_arm64.deb" -o "$tmp/ladspa.deb"
|
||||||
|
mkdir "$tmp/pulse" "$tmp/ladspa"
|
||||||
|
(cd "$tmp/pulse"; ar p "$tmp/pulse.deb" data.tar.xz | tar -xJf -)
|
||||||
|
(cd "$tmp/ladspa"; ar p "$tmp/ladspa.deb" data.tar.xz | tar -xJf -)
|
||||||
|
header=$(find "$tmp/ladspa" -name ladspa.h -print -quit)
|
||||||
|
module=$(find "$tmp/pulse" -name module-ladspa-sink.so -print -quit)
|
||||||
|
test -f "$header" && test -f "$module"
|
||||||
|
zig cc -target aarch64-linux-gnu.2.30 -mcpu=cortex_a53 -O3 -Wall -Wextra -Werror -shared -fPIC -I "$(dirname "$header")" filter.c -lm -o "$output/alice_room_eq.so"
|
||||||
|
cp "$module" "$output/module-ladspa-sink.so"
|
||||||
Executable
+549
@@ -0,0 +1,549 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Measure Mini 2 room response, fit bounded EQ and operate the station over SSH."""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import select
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
RATE = 48000
|
||||||
|
PEAK = 10 ** (-16 / 20)
|
||||||
|
START = 0.90
|
||||||
|
SWEEP = 4.5
|
||||||
|
TAIL = 0.60
|
||||||
|
MARK1 = 0.50
|
||||||
|
MARK2 = START + SWEEP + TAIL
|
||||||
|
GRID = 100 * 2 ** (np.arange(0, 73) / 12)
|
||||||
|
MAX_RECORDING_SECONDS = 35
|
||||||
|
|
||||||
|
|
||||||
|
def chirp(duration, start, end, logarithmic=False):
|
||||||
|
t = np.arange(round(duration * RATE)) / RATE
|
||||||
|
if logarithmic:
|
||||||
|
k = np.log(end / start) / duration
|
||||||
|
cycles = start * np.expm1(k * t) / k
|
||||||
|
else:
|
||||||
|
cycles = start * t + (end - start) / (2 * duration) * t**2
|
||||||
|
fade = round(0.025 * RATE)
|
||||||
|
envelope = np.ones(len(t))
|
||||||
|
envelope[:fade] = np.sin(np.linspace(0, np.pi / 2, fade)) ** 2
|
||||||
|
envelope[-fade:] = envelope[:fade][::-1]
|
||||||
|
return PEAK * np.sin(2 * np.pi * cycles) * envelope
|
||||||
|
|
||||||
|
|
||||||
|
FIRST_MARK = chirp(0.15, 1700, 6400)
|
||||||
|
LAST_MARK = chirp(0.15, 6800, 2100)
|
||||||
|
EXCITATION = chirp(SWEEP, 95, 11500, logarithmic=True)
|
||||||
|
|
||||||
|
|
||||||
|
def probe():
|
||||||
|
samples = np.zeros(round((MARK2 + 0.45) * RATE))
|
||||||
|
for offset, signal in [(MARK1, FIRST_MARK), (START, EXCITATION), (MARK2, LAST_MARK)]:
|
||||||
|
first = round(offset * RATE)
|
||||||
|
samples[first:first + len(signal)] = signal
|
||||||
|
return np.round(samples * 32767).astype('<i2')
|
||||||
|
|
||||||
|
|
||||||
|
def read_recording(path, internal):
|
||||||
|
if internal:
|
||||||
|
samples = np.fromfile(path, dtype='<i2')
|
||||||
|
if len(samples) != 9 * RATE * 6:
|
||||||
|
raise ValueError(f'{path}: ожидается 9 секунд шестиканального PCM')
|
||||||
|
return samples.reshape(-1, 6)[:, :4].astype(float) / 32768
|
||||||
|
with wave.open(str(path)) as reader:
|
||||||
|
rate = reader.getframerate()
|
||||||
|
width = reader.getsampwidth()
|
||||||
|
channels = reader.getnchannels()
|
||||||
|
n = reader.getnframes()
|
||||||
|
if width not in (2, 3, 4) or not 1 <= channels <= 8 or not 16000 <= rate <= 96000:
|
||||||
|
raise ValueError(f'{path}: нужен WAV PCM 16/24/32 бит, 16–96 кГц')
|
||||||
|
if not 6.4 * rate <= n <= MAX_RECORDING_SECONDS * rate:
|
||||||
|
raise ValueError(f'{path}: ожидается запись 7–35 секунд')
|
||||||
|
raw = reader.readframes(n)
|
||||||
|
if width == 3:
|
||||||
|
b = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3).astype(np.int32)
|
||||||
|
values = (b[:, 0] | (b[:, 1] << 8) | (b[:, 2] << 16))
|
||||||
|
values = ((values ^ 0x800000) - 0x800000) / 8388608
|
||||||
|
else:
|
||||||
|
values = np.frombuffer(raw, dtype='<i2' if width == 2 else '<i4') / (2 ** (width * 8 - 1))
|
||||||
|
samples = values.reshape(-1, channels)
|
||||||
|
if rate != RATE:
|
||||||
|
times = np.arange(round(len(samples) * RATE / rate)) * rate / RATE
|
||||||
|
samples = np.column_stack([np.interp(times, np.arange(len(samples)), channel) for channel in samples.T])
|
||||||
|
return samples
|
||||||
|
|
||||||
|
|
||||||
|
def marker_scores(samples, marker, first, last):
|
||||||
|
first = max(0, first)
|
||||||
|
last = min(last, len(samples) - len(marker))
|
||||||
|
if last <= first:
|
||||||
|
raise ValueError('не хватает начала или конца записи')
|
||||||
|
segment = samples[first:last + len(marker)]
|
||||||
|
length = 1 << (len(segment) + len(marker) - 2).bit_length()
|
||||||
|
correlation = np.fft.irfft(
|
||||||
|
np.fft.rfft(segment, length) * np.conj(np.fft.rfft(marker, length)), length
|
||||||
|
)[:last - first + 1]
|
||||||
|
squared = np.r_[0, np.cumsum(segment**2)]
|
||||||
|
rms = np.sqrt(np.maximum(0, squared[len(marker):] - squared[:-len(marker)]))
|
||||||
|
score = np.abs(correlation) / np.maximum(1e-12, rms * np.linalg.norm(marker))
|
||||||
|
# Reject false maxima that occur in almost silent segments.
|
||||||
|
score[rms < max(1e-5, rms.max() * 0.08)] = 0
|
||||||
|
return first, score
|
||||||
|
|
||||||
|
|
||||||
|
def synchronize(samples, span):
|
||||||
|
tolerance = round(0.001 * span)
|
||||||
|
first, starts = marker_scores(samples, FIRST_MARK, round(0.15 * RATE), len(samples) - span)
|
||||||
|
last, ends = marker_scores(samples, LAST_MARK, first + span - tolerance, len(samples))
|
||||||
|
best = None
|
||||||
|
for _ in range(24):
|
||||||
|
peak = int(np.argmax(starts))
|
||||||
|
first_score = float(starts[peak])
|
||||||
|
if first_score < 0.16:
|
||||||
|
break
|
||||||
|
start = first + peak
|
||||||
|
lo = start + span - tolerance - last
|
||||||
|
hi = min(len(ends), lo + 2 * tolerance + 1)
|
||||||
|
if hi > lo:
|
||||||
|
end = lo + int(np.argmax(ends[lo:hi]))
|
||||||
|
last_score = float(ends[end])
|
||||||
|
score = first_score * last_score
|
||||||
|
if last_score >= 0.16 and (best is None or score > best[0]):
|
||||||
|
best = score, start, last + end, min(first_score, last_score)
|
||||||
|
exclusion = RATE // 200
|
||||||
|
starts[max(0, peak - exclusion):peak + exclusion + 1] = 0
|
||||||
|
if best is None:
|
||||||
|
raise ValueError('не найдены две согласованные синхрометки')
|
||||||
|
_, start, end, confidence = best
|
||||||
|
return start, (end - start) / span, confidence
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_snr(received, noise):
|
||||||
|
n = 8192
|
||||||
|
window = np.hanning(n)
|
||||||
|
frequencies = np.fft.rfftfreq(n, 1 / RATE)
|
||||||
|
floor = np.abs(np.fft.rfft(noise[-n:] * window)) ** 2
|
||||||
|
result = np.full(len(GRID), -100.0)
|
||||||
|
for index, frequency in enumerate(GRID):
|
||||||
|
center = round(SWEEP * RATE * np.log(frequency / 95) / np.log(11500 / 95))
|
||||||
|
if center < n // 2:
|
||||||
|
continue
|
||||||
|
section = received[center - n // 2:center + n // 2]
|
||||||
|
power = np.abs(np.fft.rfft(section * window)) ** 2
|
||||||
|
band = np.abs(frequencies - frequency) < max(12, 0.10 * frequency)
|
||||||
|
ratio = max(1e-20, power[band].sum()) / max(1e-20, floor[band].sum())
|
||||||
|
result[index] = 10 * np.log10(ratio)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def response(samples):
|
||||||
|
if np.max(np.abs(samples)) >= 0.985:
|
||||||
|
raise ValueError('микрофон перегружен, снизь уровень записи')
|
||||||
|
span = round((MARK2 - MARK1) * RATE)
|
||||||
|
start, drift, confidence = synchronize(samples, span)
|
||||||
|
offset = (START - MARK1) * RATE
|
||||||
|
n = len(EXCITATION) + round(0.44 * RATE)
|
||||||
|
times = start + drift * (offset + np.arange(n))
|
||||||
|
received = np.interp(times, np.arange(len(samples)), samples)
|
||||||
|
noise = samples[max(0, start - RATE // 4):max(0, start - RATE // 25)]
|
||||||
|
if len(noise) < 8192:
|
||||||
|
raise ValueError('недостаточно тишины перед сигналом')
|
||||||
|
snr = 20 * np.log10(max(1e-12, np.std(received)) / max(1e-9, np.std(noise)))
|
||||||
|
band_snr = sweep_snr(received, noise)
|
||||||
|
mid_snr = np.median(band_snr[(GRID >= 125) & (GRID <= 920)])
|
||||||
|
if snr < 6 or mid_snr < 15:
|
||||||
|
raise ValueError(f'слишком много шума: {snr:.0f} дБ в записи, {mid_snr:.0f} дБ в полосе коррекции')
|
||||||
|
fft_size = 1 << (n - 1).bit_length()
|
||||||
|
excitation = np.fft.rfft(EXCITATION, fft_size)
|
||||||
|
power = np.abs(excitation) ** 2
|
||||||
|
transfer = np.fft.rfft(received, fft_size) * np.conj(excitation) / (power + power.max() * 1e-8)
|
||||||
|
ir = np.fft.irfft(transfer)
|
||||||
|
# Keep nearby reflections; taper the end so they don't create a sharp window edge.
|
||||||
|
window = np.roll(ir, round(0.008 * RATE))[:round(0.388 * RATE)].copy()
|
||||||
|
fade = RATE // 10
|
||||||
|
window[-fade:] *= np.cos(np.linspace(0, np.pi / 2, fade)) ** 2
|
||||||
|
spectrum = np.abs(np.fft.rfft(window, fft_size))
|
||||||
|
frequencies = np.fft.rfftfreq(fft_size, 1 / RATE)
|
||||||
|
# Fractional-octave power averaging keeps narrow nulls out of the fit.
|
||||||
|
db = np.array([
|
||||||
|
10 * np.log10(max(1e-20, np.mean(spectrum[(frequencies >= f / 2**(1/12)) &
|
||||||
|
(frequencies <= f * 2**(1/12))] ** 2)))
|
||||||
|
for f in GRID
|
||||||
|
])
|
||||||
|
reference = np.median(db[(GRID >= 750) & (GRID <= 1900)])
|
||||||
|
return db - reference, {
|
||||||
|
'snrDb': round(float(snr), 1),
|
||||||
|
'correctionBandSnrDb': round(float(mid_snr), 1),
|
||||||
|
'driftPpm': round((drift - 1) * 1e6),
|
||||||
|
'sync': round(confidence, 2),
|
||||||
|
'_bandSnr': band_snr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def measurement(path, internal, keep_channels=False):
|
||||||
|
channels = read_recording(path, internal)
|
||||||
|
traces = []
|
||||||
|
quality = []
|
||||||
|
failures = []
|
||||||
|
for channel in channels.T:
|
||||||
|
try:
|
||||||
|
trace, q = response(channel)
|
||||||
|
traces.append(trace)
|
||||||
|
quality.append(q)
|
||||||
|
except ValueError as exc:
|
||||||
|
failures.append(str(exc))
|
||||||
|
if not traces or (internal and len(traces) < 2):
|
||||||
|
raise ValueError(f'{path}: ' + '; '.join(dict.fromkeys(failures)))
|
||||||
|
# Each external file is one position, even when the recorder supplies stereo.
|
||||||
|
return (np.array(traces) if keep_channels else np.median(traces, axis=0)), quality
|
||||||
|
|
||||||
|
|
||||||
|
def filter_curve(frequency, q, gain):
|
||||||
|
omega = 2 * np.pi * frequency / RATE
|
||||||
|
a = 10 ** (gain / 40)
|
||||||
|
alpha = np.sin(omega) / (2 * q)
|
||||||
|
z = np.exp(-2j * np.pi * GRID / RATE)
|
||||||
|
b = (1 + alpha * a) - 2 * np.cos(omega) * z + (1 - alpha * a) * z**2
|
||||||
|
d = (1 + alpha / a) - 2 * np.cos(omega) * z + (1 - alpha / a) * z**2
|
||||||
|
return 20 * np.log10(np.abs(b / d))
|
||||||
|
|
||||||
|
|
||||||
|
def fit(traces, internal, reference=None, reliable=None):
|
||||||
|
measured = np.median(traces, axis=0)
|
||||||
|
spread = np.percentile(traces, 75, axis=0) - np.percentile(traces, 25, axis=0)
|
||||||
|
target = -0.7 * np.log2(GRID / 1000)
|
||||||
|
if reference is not None:
|
||||||
|
target = reference
|
||||||
|
mask = (GRID >= (150 if internal else 125)) & (GRID <= (500 if internal else 920))
|
||||||
|
if reliable is not None:
|
||||||
|
mask &= reliable
|
||||||
|
# Only cut peaks observed in most positions. Keep a margin for mic coloration.
|
||||||
|
common = np.percentile(traces, 25, axis=0) - target
|
||||||
|
margin = 3.0 if internal or len(traces) == 1 else 1.6
|
||||||
|
excess = np.where(mask, np.maximum(0, common - margin - 0.35 * spread), 0)
|
||||||
|
curve = np.zeros_like(GRID)
|
||||||
|
filters = []
|
||||||
|
q_values = [0.7, 1.0, 1.2] if internal else [0.7, 1.0, 1.4, 2.0, 2.8]
|
||||||
|
max_gain = 2 if internal else 3 if len(traces) == 1 else 5
|
||||||
|
total_cut = 3 if internal else 6
|
||||||
|
for _ in range(2 if internal else 5):
|
||||||
|
residual = excess + curve
|
||||||
|
best = 0.0
|
||||||
|
choice = None
|
||||||
|
base_loss = np.sum((curve + excess) ** 2 * np.where(mask, 1, 1.7))
|
||||||
|
for frequency in GRID[mask & (residual > 0.9)]:
|
||||||
|
for q in q_values:
|
||||||
|
for gain in np.arange(1, max_gain + 0.1, 0.5):
|
||||||
|
correction = filter_curve(round(frequency), q, -gain)
|
||||||
|
proposed = curve + correction
|
||||||
|
if -proposed.min() > total_cut + 0.005:
|
||||||
|
continue
|
||||||
|
# Penalize reductions where one or more seats already have a dip.
|
||||||
|
dip = np.maximum(0, -common - 1)
|
||||||
|
loss = np.sum((proposed + excess)**2 * np.where(mask, 1, 1.7) +
|
||||||
|
np.abs(proposed) * np.minimum(dip, 8) * 0.3)
|
||||||
|
improvement = base_loss - loss
|
||||||
|
if improvement > best:
|
||||||
|
best, choice = improvement, (round(frequency), q, -gain, proposed)
|
||||||
|
if choice is None or best < 0.8:
|
||||||
|
break
|
||||||
|
frequency, q, gain, curve = choice
|
||||||
|
filters.append({'hz': frequency, 'q': q, 'db': float(gain)})
|
||||||
|
preamp = -0.6 if filters else 0
|
||||||
|
# Pink-weighted estimate, for an initial level match before listening.
|
||||||
|
match_band = (GRID >= 160) & (GRID <= 4500)
|
||||||
|
dry = preamp + 10 * np.log10(np.mean(10 ** (curve[match_band] / 10)))
|
||||||
|
return measured, spread, target, curve, filters, preamp, round(float(dry), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def write_analysis(args):
|
||||||
|
measured = [measurement(Path(path), args.internal, keep_channels=args.internal) for path in args.files]
|
||||||
|
traces = np.array([np.median(trace, axis=0) if args.internal else trace for trace, _ in measured])
|
||||||
|
fit_traces = np.concatenate([trace for trace, _ in measured]) if args.internal else traces
|
||||||
|
reference = None
|
||||||
|
if args.reference:
|
||||||
|
reference = np.median([measurement(Path(path), True)[0] for path in args.reference], axis=0)
|
||||||
|
band_snr = [[channel.pop('_bandSnr') for channel in quality] for _, quality in measured]
|
||||||
|
if args.internal:
|
||||||
|
reliable = np.percentile(np.concatenate(band_snr), 25, axis=0) >= 12
|
||||||
|
else:
|
||||||
|
reliable = np.min([np.median(channels, axis=0) for channels in band_snr], axis=0) >= 12
|
||||||
|
before, spread, target, curve, filters, preamp, dry = fit(fit_traces, args.internal, reference, reliable)
|
||||||
|
report = {
|
||||||
|
'level': 'built-in' if args.internal else 'external',
|
||||||
|
'positions': len(traces),
|
||||||
|
'placementReference': bool(args.reference),
|
||||||
|
'quality': [quality for _, quality in measured],
|
||||||
|
'filters': filters,
|
||||||
|
'preampDb': preamp,
|
||||||
|
'referenceGainDb': dry,
|
||||||
|
}
|
||||||
|
prefix = Path(args.output)
|
||||||
|
prefix.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
prefix.with_suffix('.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
|
||||||
|
after = before + curve
|
||||||
|
with prefix.with_suffix('.csv').open('w', newline='') as file:
|
||||||
|
writer = csv.writer(file)
|
||||||
|
writer.writerow(['Hz', 'before_db', 'predicted_db', 'target_db', 'correction_band', 'interquartile_db',
|
||||||
|
*[f'position_{i+1}_db' for i in range(len(traces))]])
|
||||||
|
band = (GRID >= (150 if args.internal else 125)) & (GRID <= (500 if args.internal else 920))
|
||||||
|
for row in zip(GRID, before, after, target, band, spread, *traces):
|
||||||
|
writer.writerow([round(float(x), 3) for x in row])
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
print(f'Профиль и кривые: {prefix}.json, {prefix}.csv')
|
||||||
|
if not filters:
|
||||||
|
print('Устойчивых пиков для безопасной коррекции не найдено.')
|
||||||
|
|
||||||
|
|
||||||
|
def verify(args):
|
||||||
|
with Path(args.before).open(newline='') as file:
|
||||||
|
rows = list(csv.DictReader(file))
|
||||||
|
frequencies = np.array([float(row['Hz']) for row in rows])
|
||||||
|
if len(frequencies) != len(GRID) or not np.allclose(frequencies, GRID, atol=0.001):
|
||||||
|
raise ValueError('CSV должен быть получен командой fit')
|
||||||
|
positions = [key for key in rows[0] if key.startswith('position_')]
|
||||||
|
if len(positions) != len(args.files):
|
||||||
|
raise ValueError('нужен повторный замер в каждой исходной точке и в том же порядке')
|
||||||
|
baseline = np.array([[float(row[key]) for row in rows] for key in positions])
|
||||||
|
after = np.array([measurement(Path(path), args.internal)[0] for path in args.files])
|
||||||
|
target = np.array([float(row['target_db']) for row in rows])
|
||||||
|
band = (GRID >= (150 if args.internal else 125)) & (GRID <= (500 if args.internal else 920))
|
||||||
|
error = lambda traces: np.sqrt(np.mean((traces[:, band] - target[band]) ** 2, axis=1))
|
||||||
|
result = {
|
||||||
|
'rmsDeviationBeforeDb': np.round(error(baseline), 2).tolist(),
|
||||||
|
'rmsDeviationAfterDb': np.round(error(after), 2).tolist(),
|
||||||
|
'meanChangeDb': round(float(np.mean(error(after) - error(baseline))), 2),
|
||||||
|
}
|
||||||
|
prefix = Path(args.output)
|
||||||
|
prefix.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
prefix.with_suffix('.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||||
|
with prefix.with_suffix('.csv').open('w', newline='') as file:
|
||||||
|
writer = csv.writer(file)
|
||||||
|
writer.writerow(['Hz', 'before_db', 'after_db', 'target_db', 'correction_band',
|
||||||
|
*[f'position_{i+1}_db' for i in range(len(baseline))],
|
||||||
|
*[f'after_position_{i+1}_db' for i in range(len(after))]])
|
||||||
|
for row in zip(GRID, np.median(baseline, axis=0), np.median(after, axis=0), target,
|
||||||
|
band, *baseline, *after):
|
||||||
|
writer.writerow([round(float(x), 3) for x in row])
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def plot(args):
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
with Path(args.csv).open(newline='') as file:
|
||||||
|
rows = list(csv.DictReader(file))
|
||||||
|
frequencies = np.array([float(row['Hz']) for row in rows])
|
||||||
|
fig, axis = plt.subplots(figsize=(9, 4.6), layout='constrained')
|
||||||
|
for prefix, color in [('position_', '#425979'), ('after_position_', '#1b8c78')]:
|
||||||
|
columns = [key for key in rows[0] if key.startswith(prefix)]
|
||||||
|
if len(columns) > 1:
|
||||||
|
for key in columns:
|
||||||
|
axis.semilogx(frequencies, [float(row[key]) for row in rows], color=color, alpha=0.20)
|
||||||
|
for key, label, color, style in (
|
||||||
|
('before_db', 'До коррекции', '#425979', '-'),
|
||||||
|
('predicted_db', 'Расчёт после коррекции', '#1b8c78', '--'),
|
||||||
|
('after_db', 'Повторный замер', '#1b8c78', '-'),
|
||||||
|
('target_db', 'Цель в рабочем диапазоне', '#8c7290', ':'),
|
||||||
|
):
|
||||||
|
if key in rows[0]:
|
||||||
|
levels = np.array([float(row[key]) for row in rows])
|
||||||
|
if key == 'predicted_db' and np.allclose(levels, [float(row['before_db']) for row in rows]):
|
||||||
|
continue
|
||||||
|
keep = (np.array([float(row['correction_band']) for row in rows]) > 0
|
||||||
|
if key == 'target_db' else np.ones(len(rows), dtype=bool))
|
||||||
|
axis.semilogx(frequencies[keep], levels[keep],
|
||||||
|
label=label, color=color, linestyle=style, linewidth=2)
|
||||||
|
axis.set(xlabel='Частота, Гц', ylabel='Относительный уровень, дБ', xlim=(100, 6400))
|
||||||
|
axis.grid(alpha=0.25, which='both')
|
||||||
|
axis.legend()
|
||||||
|
fig.savefig(args.output, dpi=150)
|
||||||
|
|
||||||
|
|
||||||
|
def record_mac(args):
|
||||||
|
output = Path(args.output)
|
||||||
|
if output.exists():
|
||||||
|
raise ValueError(f'файл уже существует: {output}')
|
||||||
|
root = Path(__file__).resolve().parent
|
||||||
|
cache = Path.home() / 'Library/Caches/alice-room-eq'
|
||||||
|
cache.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
binary = cache / 'record-mac'
|
||||||
|
source, plist = root / 'record-mac.swift', root / 'record-mac.plist'
|
||||||
|
if not binary.exists() or binary.stat().st_mtime < max(source.stat().st_mtime, plist.stat().st_mtime):
|
||||||
|
compiled = cache / f'record-mac-{secrets.token_hex(8)}'
|
||||||
|
try:
|
||||||
|
subprocess.run(['swiftc', '-parse-as-library', '-O', '-Xlinker', '-sectcreate',
|
||||||
|
'-Xlinker', '__TEXT', '-Xlinker', '__info_plist', '-Xlinker', str(plist),
|
||||||
|
str(source), '-o', str(compiled)], check=True)
|
||||||
|
compiled.replace(binary)
|
||||||
|
finally:
|
||||||
|
compiled.unlink(missing_ok=True)
|
||||||
|
with subprocess.Popen([str(binary), str(output)], stdout=subprocess.PIPE) as recorder:
|
||||||
|
try:
|
||||||
|
if not select.select([recorder.stdout], [], [], 30)[0] or recorder.stdout.readline() != b'ready\n':
|
||||||
|
raise ValueError('микрофон не готов: проверь разрешение macOS и выбранный вход')
|
||||||
|
time.sleep(0.8)
|
||||||
|
ssh(args, '/data/opt/alice-backend/room-eq.sh play ' + args.preset)
|
||||||
|
if recorder.wait(timeout=17) != 0:
|
||||||
|
raise ValueError('запись не завершилась')
|
||||||
|
except BaseException:
|
||||||
|
if recorder.poll() is None:
|
||||||
|
recorder.kill()
|
||||||
|
recorder.wait()
|
||||||
|
output.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def ssh(args, command, **kwargs):
|
||||||
|
return subprocess.run(
|
||||||
|
['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
|
||||||
|
'-i', str(Path(args.identity).expanduser()), '-p', str(args.port), args.host, command],
|
||||||
|
check=True, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install(args):
|
||||||
|
root = Path(__file__).resolve().parent
|
||||||
|
remote = '/data/opt/alice-backend'
|
||||||
|
ssh(args, f'mkdir -p {remote}/room-eq')
|
||||||
|
for local, target in [
|
||||||
|
(Path(args.build) / 'module-ladspa-sink.so', 'spotify/lib/module-ladspa-sink.so'),
|
||||||
|
(Path(args.build) / 'alice_room_eq.so', 'room-eq/alice_room_eq.so'),
|
||||||
|
(root.parent / 'station/room-eq.sh', 'room-eq.sh'),
|
||||||
|
]:
|
||||||
|
with local.open('rb') as source:
|
||||||
|
ssh(args, f'umask 077; cat > {remote}/{target}.new && mv {remote}/{target}.new {remote}/{target}',
|
||||||
|
stdin=source)
|
||||||
|
ssh(args, f'umask 077; cat > {remote}/room-eq/probe.pcm.new && '
|
||||||
|
f'mv {remote}/room-eq/probe.pcm.new {remote}/room-eq/probe.pcm && chmod 700 {remote}/room-eq.sh',
|
||||||
|
input=probe().tobytes())
|
||||||
|
|
||||||
|
|
||||||
|
def apply(args):
|
||||||
|
profile = json.loads(Path(args.profile).read_text())
|
||||||
|
try:
|
||||||
|
filters = [
|
||||||
|
{'hz': float(band['hz']), 'q': float(band['q']), 'db': float(band['db'])}
|
||||||
|
for band in profile['filters']
|
||||||
|
]
|
||||||
|
preamp = float(profile['preampDb'])
|
||||||
|
reference = float(profile['referenceGainDb'])
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise ValueError('некорректный формат профиля') from exc
|
||||||
|
if not 1 <= len(filters) <= 5:
|
||||||
|
raise ValueError('профиль должен содержать от одного до пяти фильтров')
|
||||||
|
if not -12 <= reference <= 0 or not -12 <= preamp <= 0:
|
||||||
|
raise ValueError('предусиление должно быть в диапазоне от −12 до 0 дБ')
|
||||||
|
curve = np.zeros_like(GRID)
|
||||||
|
for band in filters:
|
||||||
|
if not (100 <= band['hz'] <= 1600 and 0.5 <= band['q'] <= 3 and -6 <= band['db'] <= 0):
|
||||||
|
raise ValueError('фильтр выходит за допустимые пределы')
|
||||||
|
curve += filter_curve(band['hz'], band['q'], band['db'])
|
||||||
|
if curve.min() < -6.01:
|
||||||
|
raise ValueError('суммарный срез не должен превышать 6 дБ')
|
||||||
|
|
||||||
|
def controls(preamp, bands):
|
||||||
|
values = [preamp]
|
||||||
|
for band in bands:
|
||||||
|
values.extend([band['hz'], band['q'], band['db']])
|
||||||
|
values.extend([300, 1, 0] * (5 - len(bands)))
|
||||||
|
return ','.join(f'{float(x):.3f}' for x in values)
|
||||||
|
eq = controls(preamp, filters)
|
||||||
|
ref = controls(reference, [])
|
||||||
|
ssh(args, '/data/opt/alice-backend/room-eq.sh apply', input=f'{args.mode}\n{eq}\n{ref}\n'.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--host', default='root@192.168.1.135')
|
||||||
|
parser.add_argument('--port', type=int, default=2222)
|
||||||
|
parser.add_argument('--identity', default='~/.ssh/yandex_mini2')
|
||||||
|
sub = parser.add_subparsers(dest='command', required=True)
|
||||||
|
sub.add_parser('install').add_argument('--build', default='room-eq/build')
|
||||||
|
sub.add_parser('probe').add_argument('output')
|
||||||
|
capture = sub.add_parser('capture')
|
||||||
|
capture.add_argument('output')
|
||||||
|
capture.add_argument('--preset', choices=['baseline', 'corrected'], default='baseline')
|
||||||
|
recorder = sub.add_parser('record-mac')
|
||||||
|
recorder.add_argument('output')
|
||||||
|
recorder.add_argument('--preset', choices=['baseline', 'corrected'], default='baseline')
|
||||||
|
play = sub.add_parser('play')
|
||||||
|
play.add_argument('preset', choices=['baseline', 'corrected'], default='baseline', nargs='?')
|
||||||
|
analysis = sub.add_parser('fit')
|
||||||
|
analysis.add_argument('files', nargs='+')
|
||||||
|
analysis.add_argument('--internal', action='store_true')
|
||||||
|
analysis.add_argument('--reference', nargs='+', help='эталонные сырые записи со встроенных микрофонов')
|
||||||
|
analysis.add_argument('--output', default='room-profile')
|
||||||
|
check = sub.add_parser('verify')
|
||||||
|
check.add_argument('before')
|
||||||
|
check.add_argument('files', nargs='+')
|
||||||
|
check.add_argument('--internal', action='store_true')
|
||||||
|
check.add_argument('--output', default='room-verification')
|
||||||
|
chart = sub.add_parser('plot')
|
||||||
|
chart.add_argument('csv')
|
||||||
|
chart.add_argument('output')
|
||||||
|
application = sub.add_parser('apply')
|
||||||
|
application.add_argument('profile')
|
||||||
|
application.add_argument('--mode', choices=['eq', 'ref', 'off'], default='off')
|
||||||
|
for name in ('eq', 'ref', 'off', 'disable', 'status', 'ab'):
|
||||||
|
sub.add_parser(name)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
if args.command == 'fit':
|
||||||
|
if args.reference and not args.internal:
|
||||||
|
raise ValueError('--reference поддерживается только для встроенных микрофонов')
|
||||||
|
write_analysis(args)
|
||||||
|
elif args.command == 'verify':
|
||||||
|
verify(args)
|
||||||
|
elif args.command == 'plot':
|
||||||
|
plot(args)
|
||||||
|
elif args.command == 'record-mac':
|
||||||
|
record_mac(args)
|
||||||
|
elif args.command == 'probe':
|
||||||
|
with wave.open(args.output, 'wb') as file:
|
||||||
|
file.setparams((1, 2, RATE, 0, 'NONE', 'not compressed'))
|
||||||
|
file.writeframes(probe().tobytes())
|
||||||
|
elif args.command == 'install':
|
||||||
|
install(args)
|
||||||
|
elif args.command == 'apply':
|
||||||
|
apply(args)
|
||||||
|
elif args.command == 'ab':
|
||||||
|
order = ['eq', 'ref'] if secrets.randbits(1) else ['ref', 'eq']
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = input('a/b — переключить, ? — раскрыть, q — выйти: ').strip().lower()
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
if choice == 'q':
|
||||||
|
break
|
||||||
|
if choice == '?':
|
||||||
|
print(f'A = {order[0]}, B = {order[1]}')
|
||||||
|
elif choice in ('a', 'b'):
|
||||||
|
ssh(args, '/data/opt/alice-backend/room-eq.sh ' + order[choice == 'b'])
|
||||||
|
finally:
|
||||||
|
ssh(args, '/data/opt/alice-backend/room-eq.sh off')
|
||||||
|
elif args.command == 'capture':
|
||||||
|
with Path(args.output).open('xb') as file:
|
||||||
|
try:
|
||||||
|
ssh(args, '/data/opt/alice-backend/room-eq.sh measure ' + args.preset, stdout=file)
|
||||||
|
except BaseException:
|
||||||
|
Path(args.output).unlink()
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
command = args.command + (' ' + shlex.quote(args.preset) if args.command == 'play' else '')
|
||||||
|
ssh(args, '/data/opt/alice-backend/room-eq.sh ' + command)
|
||||||
|
except (ValueError, OSError, ImportError, wave.Error, subprocess.SubprocessError) as exc:
|
||||||
|
parser.exit(1, f'{exc}\n')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#include <ladspa.h>
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
enum { BANDS = 5, INPUT = 0, OUTPUT = 1, PREAMP = 2, FIRST = 3, PORTS = FIRST + BANDS * 3 };
|
||||||
|
typedef struct { float b0, b1, b2, a1, a2, z1, z2; } Biquad;
|
||||||
|
typedef struct {
|
||||||
|
LADSPA_Data *port[PORTS];
|
||||||
|
float preamp;
|
||||||
|
Biquad band[BANDS];
|
||||||
|
unsigned count;
|
||||||
|
unsigned long rate;
|
||||||
|
} Filter;
|
||||||
|
|
||||||
|
static float bounded(float v, float low, float high, float fallback) {
|
||||||
|
return isfinite(v) ? fminf(high, fmaxf(low, v)) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
static LADSPA_Handle instantiate(const LADSPA_Descriptor *desc, unsigned long rate) {
|
||||||
|
(void)desc;
|
||||||
|
Filter *filter = calloc(1, sizeof(*filter));
|
||||||
|
if (filter) { filter->rate = rate; filter->preamp = 1; }
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void connect_port(LADSPA_Handle handle, unsigned long port, LADSPA_Data *value) {
|
||||||
|
((Filter *)handle)->port[port] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void activate(LADSPA_Handle handle) {
|
||||||
|
Filter *filter = handle;
|
||||||
|
filter->preamp = powf(10, bounded(*filter->port[PREAMP], -12, 0, 0) / 20);
|
||||||
|
filter->count = 0;
|
||||||
|
for (unsigned i = 0; i < BANDS; ++i) {
|
||||||
|
float *f = filter->port[FIRST + i * 3];
|
||||||
|
float *q = filter->port[FIRST + i * 3 + 1];
|
||||||
|
float *g = filter->port[FIRST + i * 3 + 2];
|
||||||
|
float gain = bounded(*g, -6, 0, 0);
|
||||||
|
if (gain > -0.01f) continue;
|
||||||
|
float omega = 6.28318530718f * bounded(*f, 100, 1600, 300) / filter->rate;
|
||||||
|
float a = powf(10, gain / 40);
|
||||||
|
float alpha = sinf(omega) / (2 * bounded(*q, 0.5f, 3, 1));
|
||||||
|
float a0 = 1 + alpha / a;
|
||||||
|
float b1 = -2 * cosf(omega) / a0;
|
||||||
|
filter->band[filter->count++] = (Biquad){
|
||||||
|
(1 + alpha * a) / a0, b1, (1 - alpha * a) / a0,
|
||||||
|
b1, (1 - alpha / a) / a0, 0, 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void run(LADSPA_Handle handle, unsigned long count) {
|
||||||
|
Filter *filter = handle;
|
||||||
|
const float *input = filter->port[INPUT];
|
||||||
|
float *output = filter->port[OUTPUT];
|
||||||
|
for (unsigned long i = 0; i < count; ++i) {
|
||||||
|
float value = input[i] * filter->preamp;
|
||||||
|
for (unsigned j = 0; j < filter->count; ++j) {
|
||||||
|
Biquad *b = &filter->band[j];
|
||||||
|
float next = b->b0 * value + b->z1;
|
||||||
|
b->z1 = b->b1 * value - b->a1 * next + b->z2;
|
||||||
|
b->z2 = b->b2 * value - b->a2 * next;
|
||||||
|
value = next;
|
||||||
|
}
|
||||||
|
output[i] = bounded(value, -1, 1, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#define CONTROL (LADSPA_PORT_INPUT | LADSPA_PORT_CONTROL)
|
||||||
|
#define RANGE (LADSPA_HINT_BOUNDED_BELOW | LADSPA_HINT_BOUNDED_ABOVE)
|
||||||
|
#define BAND_HINTS {RANGE,100,1600}, {RANGE,0.5f,3}, {RANGE,-6,0}
|
||||||
|
#define BAND_NAMES(n) "Frequency " #n, "Q " #n, "Cut dB " #n
|
||||||
|
static const LADSPA_PortDescriptor types[PORTS] = {
|
||||||
|
LADSPA_PORT_INPUT | LADSPA_PORT_AUDIO, LADSPA_PORT_OUTPUT | LADSPA_PORT_AUDIO,
|
||||||
|
CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL,
|
||||||
|
CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL, CONTROL
|
||||||
|
};
|
||||||
|
static const LADSPA_PortRangeHint hints[PORTS] = {
|
||||||
|
{0,0,0}, {0,0,0}, {RANGE,-12,0}, BAND_HINTS, BAND_HINTS, BAND_HINTS, BAND_HINTS, BAND_HINTS
|
||||||
|
};
|
||||||
|
static const char *const names[PORTS] = {
|
||||||
|
"Input", "Output", "Preamp dB", BAND_NAMES(1), BAND_NAMES(2), BAND_NAMES(3), BAND_NAMES(4), BAND_NAMES(5)
|
||||||
|
};
|
||||||
|
static const LADSPA_Descriptor descriptor = {
|
||||||
|
.UniqueID = 61987, .Label = "alice_room_eq", .Properties = LADSPA_PROPERTY_HARD_RT_CAPABLE,
|
||||||
|
.Name = "Alice Room EQ", .Maker = "Alice Backend", .Copyright = "None",
|
||||||
|
.PortCount = PORTS, .PortDescriptors = types, .PortNames = names, .PortRangeHints = hints,
|
||||||
|
.instantiate = instantiate, .connect_port = connect_port, .activate = activate,
|
||||||
|
.run = run, .cleanup = free
|
||||||
|
};
|
||||||
|
|
||||||
|
const LADSPA_Descriptor *ladspa_descriptor(unsigned long index) {
|
||||||
|
return index == 0 ? &descriptor : NULL;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key><string>systems.pepega.alice.roommic</string>
|
||||||
|
<key>CFBundleName</key><string>Alice Room Mic</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key><string>Записать тестовый сигнал Mini 2 для расчёта акустической коррекции.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct RecordRoom {
|
||||||
|
static func main() throws {
|
||||||
|
guard CommandLine.arguments.count == 2 else {
|
||||||
|
throw NSError(domain: "RoomMic", code: 1)
|
||||||
|
}
|
||||||
|
let engine = AVAudioEngine()
|
||||||
|
let input = engine.inputNode
|
||||||
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
guard format.sampleRate > 0, format.channelCount > 0 else {
|
||||||
|
throw NSError(domain: "RoomMic", code: 2, userInfo: [NSLocalizedDescriptionKey: "Нет входа микрофона."])
|
||||||
|
}
|
||||||
|
let destination = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||||
|
let output = try AVAudioFile(forWriting: destination, settings: [
|
||||||
|
AVFormatIDKey: kAudioFormatLinearPCM,
|
||||||
|
AVSampleRateKey: format.sampleRate,
|
||||||
|
AVNumberOfChannelsKey: format.channelCount,
|
||||||
|
AVLinearPCMBitDepthKey: 16,
|
||||||
|
AVLinearPCMIsFloatKey: false,
|
||||||
|
AVLinearPCMIsBigEndianKey: false,
|
||||||
|
AVLinearPCMIsNonInterleaved: false,
|
||||||
|
], commonFormat: format.commonFormat, interleaved: format.isInterleaved)
|
||||||
|
let limit = Int(format.sampleRate * 13)
|
||||||
|
let complete = DispatchSemaphore(value: 0)
|
||||||
|
let state = Recording(limit)
|
||||||
|
input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
|
||||||
|
guard state.remaining > 0 else { return }
|
||||||
|
buffer.frameLength = min(buffer.frameLength, AVAudioFrameCount(state.remaining))
|
||||||
|
do {
|
||||||
|
try output.write(from: buffer)
|
||||||
|
state.remaining -= Int(buffer.frameLength)
|
||||||
|
} catch {
|
||||||
|
state.failure = error
|
||||||
|
state.remaining = 0
|
||||||
|
}
|
||||||
|
if state.remaining == 0 { complete.signal() }
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
engine.stop()
|
||||||
|
input.removeTap(onBus: 0)
|
||||||
|
}
|
||||||
|
engine.prepare()
|
||||||
|
try engine.start()
|
||||||
|
FileHandle.standardOutput.write(Data("ready\n".utf8))
|
||||||
|
guard complete.wait(timeout: .now() + 17) == .success else {
|
||||||
|
throw NSError(domain: "RoomMic", code: 3, userInfo: [NSLocalizedDescriptionKey: "Микрофон не отдал запись вовремя."])
|
||||||
|
}
|
||||||
|
if let failure = state.failure { throw failure }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Recording: @unchecked Sendable {
|
||||||
|
// The tap owns this state. The main thread reads the result after the semaphore.
|
||||||
|
var remaining: Int
|
||||||
|
var failure: Error?
|
||||||
|
|
||||||
|
init(_ remaining: Int) { self.remaining = remaining }
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
numpy>=1.26,<3
|
||||||
|
matplotlib>=3.8,<4
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from calibrate import EXCITATION, GRID, RATE, filter_curve, fit, probe, response
|
||||||
|
|
||||||
|
|
||||||
|
def artificial_recording(drift=1.0):
|
||||||
|
x = probe().astype(float) / 32768
|
||||||
|
n = 1 << (len(x) - 1).bit_length()
|
||||||
|
f = np.fft.rfftfreq(n, 1 / RATE)
|
||||||
|
omega = 2 * np.pi * f / RATE
|
||||||
|
omega0 = 2 * np.pi * 315 / RATE
|
||||||
|
a = 10 ** (6 / 40)
|
||||||
|
alpha = np.sin(omega0) / (2 * 1.3)
|
||||||
|
z = np.exp(-1j * omega)
|
||||||
|
h = ((1 + alpha*a) - 2*np.cos(omega0)*z + (1-alpha*a)*z**2) / (
|
||||||
|
(1 + alpha/a) - 2*np.cos(omega0)*z + (1-alpha/a)*z**2
|
||||||
|
)
|
||||||
|
y = np.fft.irfft(np.fft.rfft(x, n) * h, n)[:len(x)]
|
||||||
|
y = np.interp(np.arange(round(len(y)*drift)) / drift, np.arange(len(y)), y)
|
||||||
|
y = np.r_[np.zeros(RATE), y, np.zeros(RATE)]
|
||||||
|
return 0.05 * y + np.random.default_rng(3).normal(0, 2e-6, len(y))
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationTest(unittest.TestCase):
|
||||||
|
def test_recovers_peak_and_clock_drift(self):
|
||||||
|
trace, quality = response(artificial_recording(1.0005))
|
||||||
|
self.assertGreater(quality['snrDb'], 25)
|
||||||
|
self.assertAlmostEqual(quality['driftPpm'], 500, delta=30)
|
||||||
|
self.assertGreater(trace[np.argmin(abs(GRID - 315))], 4)
|
||||||
|
self.assertLess(abs(trace[np.argmin(abs(GRID - 2000))]), 1)
|
||||||
|
|
||||||
|
def test_fits_shared_peak_without_boosting_dips(self):
|
||||||
|
trace, _ = response(artificial_recording())
|
||||||
|
traces = np.stack([trace, trace - 0.2, trace + 0.3])
|
||||||
|
_, _, _, curve, filters, preamp, dry = fit(traces, False)
|
||||||
|
self.assertTrue(filters)
|
||||||
|
self.assertLessEqual(curve.max(), 1e-6)
|
||||||
|
self.assertGreaterEqual(curve.min(), -6.01)
|
||||||
|
self.assertLess(curve[np.argmin(abs(GRID-315))], -1.5)
|
||||||
|
self.assertLessEqual(dry, preamp)
|
||||||
|
self.assertGreaterEqual(filter_curve(315, 1.3, -6).min(), -6.0001)
|
||||||
|
|
||||||
|
def test_refuses_clipping_and_unsynchronized_audio(self):
|
||||||
|
audio = artificial_recording()
|
||||||
|
audio[0] = 1
|
||||||
|
with self.assertRaisesRegex(ValueError, 'перегружен'):
|
||||||
|
response(audio)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
response(np.zeros(len(EXCITATION) * 2))
|
||||||
|
with self.assertRaisesRegex(ValueError, 'синхрометки'):
|
||||||
|
response(artificial_recording(1.02))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -5,6 +5,9 @@ import { spotifyStatus, startSpotify, finishSpotify } from './spotify.mjs';
|
|||||||
|
|
||||||
const assets = new Map([
|
const assets = new Map([
|
||||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||||
|
['/room-mic', ['room-mic.html', 'text/html; charset=utf-8']],
|
||||||
|
['/room-mic.js', ['room-mic.js', 'text/javascript; charset=utf-8']],
|
||||||
|
['/room-mic-worklet.js', ['room-mic-worklet.js', 'text/javascript; charset=utf-8']],
|
||||||
['/app.js', ['app.js', 'text/javascript; charset=utf-8']],
|
['/app.js', ['app.js', 'text/javascript; charset=utf-8']],
|
||||||
['/style.css', ['style.css', 'text/css; charset=utf-8']],
|
['/style.css', ['style.css', 'text/css; charset=utf-8']],
|
||||||
].map(([route, [file, type]]) => [route, [readFileSync(new URL('./public/' + file, import.meta.url)), type]]));
|
].map(([route, [file, type]]) => [route, [readFileSync(new URL('./public/' + file, import.meta.url)), type]]));
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
class RoomMic extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.remaining = sampleRate * 15;
|
||||||
|
this.block = new Float32Array(4096);
|
||||||
|
this.offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
const block = this.block.slice(0, this.offset);
|
||||||
|
this.port.postMessage(block, [block.buffer]);
|
||||||
|
this.offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs) {
|
||||||
|
const mono = inputs[0]?.[0];
|
||||||
|
if (!mono || !this.remaining) return true;
|
||||||
|
const count = Math.min(mono.length, this.remaining);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
this.block[this.offset++] = mono[i];
|
||||||
|
if (this.offset === this.block.length) this.flush();
|
||||||
|
}
|
||||||
|
this.remaining -= count;
|
||||||
|
if (this.remaining === 0) {
|
||||||
|
if (this.offset) this.flush();
|
||||||
|
this.port.postMessage('done');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('room-mic', RoomMic);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Замер Mini 2</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<script type="module" src="/room-mic.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Замер Mini 2</h1>
|
||||||
|
<p>Поставь микрофон на высоте ушей в точке прослушивания. После начала записи запусти тестовый сигнал на колонке.</p>
|
||||||
|
<button type="button" id="record" class="primary">Начать запись на 15 секунд</button>
|
||||||
|
<p id="status" role="status"></p>
|
||||||
|
<a id="download" hidden>Скачать WAV</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
const button = document.querySelector('#record');
|
||||||
|
const status = document.querySelector('#status');
|
||||||
|
const download = document.querySelector('#download');
|
||||||
|
let objectUrl;
|
||||||
|
|
||||||
|
function wav(blocks, rate) {
|
||||||
|
const n = blocks.reduce((count, block) => count + block.length, 0);
|
||||||
|
const data = new DataView(new ArrayBuffer(44 + n * 2));
|
||||||
|
const ascii = (offset, value) => [...value].forEach((letter, i) => data.setUint8(offset + i, letter.charCodeAt(0)));
|
||||||
|
ascii(0, 'RIFF');
|
||||||
|
data.setUint32(4, 36 + n * 2, true);
|
||||||
|
ascii(8, 'WAVEfmt ');
|
||||||
|
data.setUint32(16, 16, true);
|
||||||
|
data.setUint16(20, 1, true);
|
||||||
|
data.setUint16(22, 1, true);
|
||||||
|
data.setUint32(24, rate, true);
|
||||||
|
data.setUint32(28, rate * 2, true);
|
||||||
|
data.setUint16(32, 2, true);
|
||||||
|
data.setUint16(34, 16, true);
|
||||||
|
ascii(36, 'data');
|
||||||
|
data.setUint32(40, n * 2, true);
|
||||||
|
let offset = 44;
|
||||||
|
for (const block of blocks) {
|
||||||
|
for (const value of block) {
|
||||||
|
data.setInt16(offset, Math.round(Math.max(-1, Math.min(1, value)) * 32767), true);
|
||||||
|
offset += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Blob([data], { type: 'audio/wav' });
|
||||||
|
}
|
||||||
|
|
||||||
|
button.onclick = async () => {
|
||||||
|
button.disabled = true;
|
||||||
|
let audio, stream, deadline, node;
|
||||||
|
const close = () => {
|
||||||
|
clearTimeout(deadline);
|
||||||
|
node?.disconnect();
|
||||||
|
stream?.getTracks().forEach(track => track.stop());
|
||||||
|
if (audio && audio.state !== 'closed') void audio.close();
|
||||||
|
button.disabled = false;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
audio = new AudioContext();
|
||||||
|
await audio.resume();
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: false,
|
||||||
|
audio: { channelCount: 1, echoCancellation: false, noiseSuppression: false, autoGainControl: false },
|
||||||
|
});
|
||||||
|
const settings = stream.getAudioTracks()[0].getSettings();
|
||||||
|
if ([settings.echoCancellation, settings.noiseSuppression, settings.autoGainControl].includes(true)) {
|
||||||
|
throw new Error('Браузер оставил включённой обработку микрофона. Для точного замера используй другой браузер или Mac.');
|
||||||
|
}
|
||||||
|
await audio.audioWorklet.addModule('/room-mic-worklet.js');
|
||||||
|
node = new AudioWorkletNode(audio, 'room-mic');
|
||||||
|
const blocks = [];
|
||||||
|
node.port.onmessage = ({ data }) => {
|
||||||
|
if (data !== 'done') {
|
||||||
|
blocks.push(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = wav(blocks, audio.sampleRate);
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
download.href = objectUrl;
|
||||||
|
download.download = `room-${new Date().toISOString().replaceAll(':', '-')}.wav`;
|
||||||
|
download.hidden = false;
|
||||||
|
status.textContent = 'Запись готова. Файл сохранится на этом устройстве.';
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
audio.createMediaStreamSource(stream).connect(node);
|
||||||
|
node.connect(audio.destination);
|
||||||
|
const unreported = [settings.echoCancellation, settings.noiseSuppression, settings.autoGainControl].some(value => value === undefined);
|
||||||
|
status.textContent = 'Запись идёт. Запусти тестовый сигнал.' +
|
||||||
|
(unreported ? ' Браузер не сообщил все настройки обработки.' : '');
|
||||||
|
deadline = setTimeout(() => {
|
||||||
|
close();
|
||||||
|
status.textContent = 'Запись прервалась. Оставь вкладку открытой и попробуй снова.';
|
||||||
|
}, 19000);
|
||||||
|
} catch (error) {
|
||||||
|
close();
|
||||||
|
status.textContent = error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
Executable
+187
@@ -0,0 +1,187 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
base=/data/opt/alice-backend
|
||||||
|
runtime=$base/spotify
|
||||||
|
room=$base/room-eq
|
||||||
|
profile=$room/profile
|
||||||
|
state=/tmp/alice-room-eq.state
|
||||||
|
pa=$runtime/pactl
|
||||||
|
export PULSE_SERVER=unix:/tmp/alice-pulse/native
|
||||||
|
export PULSE_RUNTIME_PATH=/tmp/alice-pulse
|
||||||
|
export PULSE_COOKIE=/tmp/alice-pulse/cookie
|
||||||
|
umask 077
|
||||||
|
exec 7>/tmp/alice-room-eq.lock
|
||||||
|
flock -n 7 || { echo 'Room EQ is busy' >&2; exit 1; }
|
||||||
|
|
||||||
|
sink_exists() { "$pa" list short sinks | awk -v name="$1" '$2 == name {found=1} END {exit !found}'; }
|
||||||
|
music_input() {
|
||||||
|
module=$("$pa" list short modules | awk '$2 == "module-remap-sink" && $0 ~ /sink_name=music([[:space:]]|$)/ {print $1; exit}')
|
||||||
|
[ -n "$module" ] || return 1
|
||||||
|
"$pa" list sink-inputs | awk -v module="$module" '
|
||||||
|
/^Sink Input #/ {id=substr($3,2)}
|
||||||
|
$1=="Owner" && $2=="Module:" && $3==module && id!="" {print id; exit}'
|
||||||
|
}
|
||||||
|
move_music() {
|
||||||
|
input=$(music_input)
|
||||||
|
[ -n "$input" ] && "$pa" move-sink-input "$input" "$1"
|
||||||
|
}
|
||||||
|
cleanup_filters() {
|
||||||
|
# Match sink arguments to prevent stale module IDs from unloading other services.
|
||||||
|
"$pa" list short modules | awk -v keep="${1:-}" '
|
||||||
|
$2 == "module-ladspa-sink" && $0 ~ /sink_name=alice_eq_[ab]_[0-9]+([[:space:]]|$)/ &&
|
||||||
|
(keep == "" || $0 !~ ("sink_name=alice_eq_[ab]_" keep "([[:space:]]|$)")) {print $1}' |
|
||||||
|
while read -r module; do "$pa" unload-module "$module" || :; done
|
||||||
|
}
|
||||||
|
validate_controls() {
|
||||||
|
printf '%s\n' "$1" | awk -F, '
|
||||||
|
NF != 16 {exit 1}
|
||||||
|
{for (i=1;i<=NF;i++) if ($i !~ /^-?[0-9]+([.][0-9]+)?$/ || length($i)>12) exit 1
|
||||||
|
if ($1 < -12 || $1 > 0) exit 1
|
||||||
|
for (i=2;i<=NF;i+=3) if ($i<100 || $i>1600 || $(i+1)<0.5 || $(i+1)>3 || $(i+2)<-6 || $(i+2)>0) exit 1}'
|
||||||
|
}
|
||||||
|
read_profile() {
|
||||||
|
IFS= read -r mode
|
||||||
|
IFS= read -r eq
|
||||||
|
IFS= read -r ref
|
||||||
|
case "$mode" in eq|ref|off) ;; *) return 1 ;; esac
|
||||||
|
validate_controls "$eq" && validate_controls "$ref"
|
||||||
|
}
|
||||||
|
load_filter() {
|
||||||
|
"$pa" load-module module-ladspa-sink "sink_name=$1" master=station \
|
||||||
|
"plugin=$room/alice_room_eq" label=alice_room_eq "control=$2" >/dev/null
|
||||||
|
}
|
||||||
|
activate() {
|
||||||
|
suffix=$$
|
||||||
|
a=alice_eq_a_$suffix
|
||||||
|
b=alice_eq_b_$suffix
|
||||||
|
if ! load_filter "$a" "$eq" || ! load_filter "$b" "$ref"; then
|
||||||
|
for name in "$a" "$b"; do
|
||||||
|
"$pa" list short modules | awk -v name="$name" '$2=="module-ladspa-sink" && $0 ~ ("sink_name=" name "([[:space:]]|$)") {print $1}' |
|
||||||
|
while read -r m; do "$pa" unload-module "$m" || :; done
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
case "$mode" in eq) target=$a ;; ref) target=$b ;; off) target=station ;; esac
|
||||||
|
if ! move_music "$target"; then
|
||||||
|
for name in "$a" "$b"; do
|
||||||
|
"$pa" list short modules | awk -v name="$name" '$2=="module-ladspa-sink" && $0 ~ ("sink_name=" name "([[:space:]]|$)") {print $1}' |
|
||||||
|
while read -r m; do "$pa" unload-module "$m" || :; done
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$suffix" > "$state"
|
||||||
|
cleanup_filters "$suffix"
|
||||||
|
}
|
||||||
|
stage_profile() {
|
||||||
|
printf '%s\n' "$mode" "$eq" "$ref" > "$profile.new"
|
||||||
|
}
|
||||||
|
calibration_ready() {
|
||||||
|
playback=$(printf '%s\n' '{"name":"spotify_control","arguments":{"action":"status"}}' | "$base/music-control")
|
||||||
|
case "$playback" in *'"status":"playing"'*|*'"status":"buffering"'*) echo 'Pause Spotify before the sweep' >&2; return 1 ;; esac
|
||||||
|
gain=$("$pa" get-sink-volume station | awk '/^Volume:/{print $5;exit}' | tr -d '%')
|
||||||
|
case "$gain" in ''|*[!0-9]*) echo 'Cannot verify speaker volume' >&2; return 1 ;; esac
|
||||||
|
if [ "$gain" -lt 10 ] || [ "$gain" -gt 65 ]; then
|
||||||
|
echo 'Set the speaker master volume between 10 and 65 percent for the sweep' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
play_probe() {
|
||||||
|
sink=$1
|
||||||
|
sink_exists "$sink" || { echo 'EQ profile unavailable' >&2; return 1; }
|
||||||
|
"$runtime/run" "$runtime/bin/pacat" --playback --device="$sink" \
|
||||||
|
--raw --rate=48000 --channels=1 --format=s16le --latency-msec=40 < "$room/probe.pcm"
|
||||||
|
}
|
||||||
|
probe_sink() {
|
||||||
|
case "${1:-baseline}" in
|
||||||
|
baseline) printf '%s\n' station ;;
|
||||||
|
corrected)
|
||||||
|
suffix=$(cat "$state")
|
||||||
|
case "$suffix" in ''|*[!0-9]*) return 1 ;; esac
|
||||||
|
printf '%s\n' "alice_eq_a_$suffix"
|
||||||
|
;;
|
||||||
|
*) return 2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
measure() {
|
||||||
|
calibration_ready
|
||||||
|
sink=$(probe_sink "${1:-baseline}")
|
||||||
|
sink_exists "$sink"
|
||||||
|
gpio=/sys/class/gpio/gpio414/value
|
||||||
|
[ "$(cat "$gpio")" = 1 ] || { echo 'Microphone muted' >&2; return 1; }
|
||||||
|
path=$(mktemp /tmp/alice-room-capture.XXXXXX)
|
||||||
|
capture= guard= restore=0
|
||||||
|
clean() {
|
||||||
|
for pid in "$guard" "$capture"; do [ -z "$pid" ] || kill "$pid" 2>/dev/null || :; done
|
||||||
|
[ ! -e "$path" ] || rm "$path"
|
||||||
|
if [ "$restore" -eq 1 ]; then /etc/init.d/S99alice start >/dev/null 7>&-; fi
|
||||||
|
}
|
||||||
|
trap clean EXIT
|
||||||
|
trap 'exit 130' HUP INT TERM
|
||||||
|
if [ -f /tmp/alice-supervisor.pid ]; then
|
||||||
|
/etc/init.d/S99alice stop
|
||||||
|
restore=1
|
||||||
|
fi
|
||||||
|
arecord -q -D plug:default -t raw -f S16_LE -c 6 -r 48000 --period-size=512 --buffer-size=4096 -d 9 > "$path" &
|
||||||
|
capture=$!
|
||||||
|
(
|
||||||
|
while kill -0 "$capture" 2>/dev/null; do
|
||||||
|
[ "$(cat "$gpio")" = 1 ] || { kill "$capture" 2>/dev/null || :; exit 1; }
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
guard=$!
|
||||||
|
sleep 1
|
||||||
|
kill -0 "$capture"
|
||||||
|
play_probe "$sink"
|
||||||
|
wait "$capture"
|
||||||
|
capture=
|
||||||
|
[ "$(cat "$gpio")" = 1 ] && [ "$(wc -c < "$path")" -eq 5184000 ]
|
||||||
|
cat "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
apply)
|
||||||
|
read_profile
|
||||||
|
stage_profile
|
||||||
|
trap 'rm -f "$profile.new"' EXIT
|
||||||
|
activate
|
||||||
|
mv "$profile.new" "$profile"
|
||||||
|
;;
|
||||||
|
restore)
|
||||||
|
[ -r "$profile" ] || exit 0
|
||||||
|
read_profile < "$profile"
|
||||||
|
activate
|
||||||
|
;;
|
||||||
|
eq|ref|off)
|
||||||
|
if [ ! -r "$profile" ] && [ "$1" = off ]; then
|
||||||
|
move_music station
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
read_profile < "$profile"
|
||||||
|
suffix=$(cat "$state")
|
||||||
|
case "$suffix" in ''|*[!0-9]*) exit 1 ;; esac
|
||||||
|
mode=$1
|
||||||
|
case "$mode" in eq) target=alice_eq_a_$suffix ;; ref) target=alice_eq_b_$suffix ;; off) target=station ;; esac
|
||||||
|
stage_profile
|
||||||
|
trap 'rm -f "$profile.new"' EXIT
|
||||||
|
move_music "$target"
|
||||||
|
mv "$profile.new" "$profile"
|
||||||
|
;;
|
||||||
|
disable)
|
||||||
|
move_music station
|
||||||
|
[ ! -e "$profile" ] || rm "$profile"
|
||||||
|
[ ! -e "$state" ] || rm "$state"
|
||||||
|
cleanup_filters
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
"$pa" list short sinks
|
||||||
|
if [ -r "$profile" ]; then read_profile < "$profile"; printf 'Mode %s\n' "$mode"; else printf 'Mode off\n'; fi
|
||||||
|
;;
|
||||||
|
play)
|
||||||
|
calibration_ready
|
||||||
|
sink=$(probe_sink "${2:-baseline}")
|
||||||
|
play_probe "$sink"
|
||||||
|
;;
|
||||||
|
measure) measure "${2:-baseline}" ;;
|
||||||
|
*) echo 'Usage: room-eq.sh status|apply|eq|ref|off|disable|restore|play [baseline|corrected]|measure [baseline|corrected]' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -63,6 +63,9 @@ while [ "$stopping" -eq 0 ]; do
|
|||||||
[0-9]|[1-9][0-9]|100) "$runtime/pactl" set-sink-volume station "$volume%" || true ;;
|
[0-9]|[1-9][0-9]|100) "$runtime/pactl" set-sink-volume station "$volume%" || true ;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
if [ -x "$base/room-eq.sh" ]; then
|
||||||
|
"$base/room-eq.sh" restore >>/tmp/alice-pulse.log 2>&1 || :
|
||||||
|
fi
|
||||||
: >/tmp/alice-soloist.log
|
: >/tmp/alice-soloist.log
|
||||||
"$runtime/run" "$runtime/soloist/soloist" --device-name "Pepega Mini 2" --api-key "$(cat "$base/soloist-key")" --data-dir "$base/soloist-data" --cache-dir /tmp/alice-spotify-cache --cache-size 100 --ws 127.0.0.1:9090 >>/tmp/alice-soloist.log 2>&1 8>&- &
|
"$runtime/run" "$runtime/soloist/soloist" --device-name "Pepega Mini 2" --api-key "$(cat "$base/soloist-key")" --data-dir "$base/soloist-data" --cache-dir /tmp/alice-spotify-cache --cache-size 100 --ws 127.0.0.1:9090 >>/tmp/alice-soloist.log 2>&1 8>&- &
|
||||||
soloist=$!
|
soloist=$!
|
||||||
|
|||||||
Reference in New Issue
Block a user