diff --git a/.gitignore b/.gitignore index 0abfce4..bedaead 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ /data/ /dist/ /wake-helper/target/ +/room-eq/build/ +/room-eq/results/ +/room-eq/__pycache__/ +/room-eq/.venv/ diff --git a/README.md b/README.md index acf54b3..81f0972 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Caddy проксирует голосовое соединение и `/healthz` Музыку на колонке воспроизводит официальный 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_play` для трека, исполнителя, альбома или плейлиста. Поддерживаются пауза, продолжение, следующий и предыдущий трек и установка громкости. Короткие команды «пауза», «выключи музыку» и «громкость 50» выполняются без озвучивания подтверждения. Для актуальных сведений и проверки неизвестных фактов Клод может вызвать [серверный веб-поиск OpenRouter](https://openrouter.ai/docs/guides/features/server-tools/web-search). diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 361d4ea..62c419b 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -10,7 +10,7 @@ alice.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 { reverse_proxy alice-backend:3000 } diff --git a/room-eq/README.md b/room-eq/README.md new file mode 100644 index 0000000..3de561d --- /dev/null +++ b/room-eq/README.md @@ -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. После перестановки колонки имеет смысл снять новый замер. diff --git a/room-eq/build.sh b/room-eq/build.sh new file mode 100755 index 0000000..15cd6fc --- /dev/null +++ b/room-eq/build.sh @@ -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" diff --git a/room-eq/calibrate.py b/room-eq/calibrate.py new file mode 100755 index 0000000..6e076e8 --- /dev/null +++ b/room-eq/calibrate.py @@ -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(' 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() diff --git a/room-eq/filter.c b/room-eq/filter.c new file mode 100644 index 0000000..ec4cd9f --- /dev/null +++ b/room-eq/filter.c @@ -0,0 +1,95 @@ +#include +#include +#include +#include + +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; +} diff --git a/room-eq/record-mac.plist b/room-eq/record-mac.plist new file mode 100644 index 0000000..9b0ec67 --- /dev/null +++ b/room-eq/record-mac.plist @@ -0,0 +1,9 @@ + + + + + CFBundleIdentifiersystems.pepega.alice.roommic + CFBundleNameAlice Room Mic + NSMicrophoneUsageDescriptionЗаписать тестовый сигнал Mini 2 для расчёта акустической коррекции. + + diff --git a/room-eq/record-mac.swift b/room-eq/record-mac.swift new file mode 100644 index 0000000..bf6cae8 --- /dev/null +++ b/room-eq/record-mac.swift @@ -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 } +} diff --git a/room-eq/requirements.txt b/room-eq/requirements.txt new file mode 100644 index 0000000..1f55c91 --- /dev/null +++ b/room-eq/requirements.txt @@ -0,0 +1,2 @@ +numpy>=1.26,<3 +matplotlib>=3.8,<4 diff --git a/room-eq/test_calibrate.py b/room-eq/test_calibrate.py new file mode 100644 index 0000000..d2b2c53 --- /dev/null +++ b/room-eq/test_calibrate.py @@ -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() diff --git a/server/admin.mjs b/server/admin.mjs index 458d7f0..d59d635 100644 --- a/server/admin.mjs +++ b/server/admin.mjs @@ -5,6 +5,9 @@ import { spotifyStatus, startSpotify, finishSpotify } from './spotify.mjs'; const assets = new Map([ ['/', ['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']], ['/style.css', ['style.css', 'text/css; charset=utf-8']], ].map(([route, [file, type]]) => [route, [readFileSync(new URL('./public/' + file, import.meta.url)), type]])); diff --git a/server/public/room-mic-worklet.js b/server/public/room-mic-worklet.js new file mode 100644 index 0000000..37f29df --- /dev/null +++ b/server/public/room-mic-worklet.js @@ -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); diff --git a/server/public/room-mic.html b/server/public/room-mic.html new file mode 100644 index 0000000..76716ad --- /dev/null +++ b/server/public/room-mic.html @@ -0,0 +1,19 @@ + + + + + + Замер Mini 2 + + + + +
+

Замер Mini 2

+

Поставь микрофон на высоте ушей в точке прослушивания. После начала записи запусти тестовый сигнал на колонке.

+ +

+ +
+ + diff --git a/server/public/room-mic.js b/server/public/room-mic.js new file mode 100644 index 0000000..a87b9a6 --- /dev/null +++ b/server/public/room-mic.js @@ -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; + } +}; diff --git a/station/room-eq.sh b/station/room-eq.sh new file mode 100755 index 0000000..965b495 --- /dev/null +++ b/station/room-eq.sh @@ -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 diff --git a/station/spotify-supervisor.sh b/station/spotify-supervisor.sh index 1234d3d..18d0261 100755 --- a/station/spotify-supervisor.sh +++ b/station/spotify-supervisor.sh @@ -63,6 +63,9 @@ while [ "$stopping" -eq 0 ]; do [0-9]|[1-9][0-9]|100) "$runtime/pactl" set-sink-volume station "$volume%" || true ;; esac fi + if [ -x "$base/room-eq.sh" ]; then + "$base/room-eq.sh" restore >>/tmp/alice-pulse.log 2>&1 || : + fi : >/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>&- & soloist=$!