Add measured room EQ for Mini 2

This commit is contained in:
2026-11-11 21:39:29 +03:00
parent a318099add
commit c2a0a7bc2f
17 changed files with 1188 additions and 1 deletions
+4
View File
@@ -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/
+2
View File
@@ -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
View File
@@ -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
} }
+61
View File
@@ -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. После перестановки колонки имеет смысл снять новый замер.
+20
View File
@@ -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"
+549
View File
@@ -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()
+95
View File
@@ -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;
}
+9
View File
@@ -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>
+61
View File
@@ -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 }
}
+2
View File
@@ -0,0 +1,2 @@
numpy>=1.26,<3
matplotlib>=3.8,<4
+57
View File
@@ -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()
+3
View File
@@ -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]]));
+32
View File
@@ -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);
+19
View File
@@ -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>
+83
View File
@@ -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;
}
};
+187
View File
@@ -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
+3
View File
@@ -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=$!