Как определить BPM и тональность трека за секунды на телефоне без ML? Разбираем пайплайн на Python: спектральный поток, автокорреляция, хромограмма. Попробуйте сами!
Представьте: вам нужно за секунды узнать BPM и тональность трека прямо на телефоне, без интернета и чёрных ящиков ML. Обычные библиотеки не подходят — пришлось собирать пайплайн с нуля. В этой статье я покажу, как это работает на Python, с кодом и формулами.
Темп — это периодичность энергии трека. Наш план: найти моменты ударов (onset) и измерить расстояние между ними.
Сначала вычисляем кратковременное преобразование Фурье (STFT) и ищем резкие увеличения магнитуды от кадра к кадру. Это и есть спектральный поток:
import numpy as np
from scipy.signal import stft
def spectral_flux(audio, sr=22050, n_fft=1024, hop_length=512):
# STFT: частоты x время
f, t, Zxx = stft(audio, fs=sr, nperseg=n_fft, noverlap=n_fft-hop_length)
mag = np.abs(Zxx) # магнитуда
# положительные изменения между кадрами
diff = np.diff(mag, axis=1)
flux = np.sum(np.maximum(0, diff), axis=0)
return fluxПики flux — это onsets: удары бочки, хлопки синтов. Получаем огибающую onsets.
Теперь ищем, с какой задержкой огибающая лучше всего совпадает сама с собой — это период бита:
def autocorrelation(flux):
# автокорреляция: свёртка сигнала с самим собой
corr = np.correlate(flux, flux, mode='full')
# берём только положительные задержки
corr = corr[len(corr)//2:]
return corrИндекс максимума corr (кроме нуля) — период в кадрах. Переводим в BPM: bpm = 60 * sr / (hop_length * lag).
Часто автокорреляция даёт кратные значения (174 вместо 87). Решение: взвешиваем кандидатов по их кратным (0.5×, 2×) и сворачиваем октавы в диапазон 60–180 BPM с мягким предпочтением типичных танцевальных темпов:
def fold_octaves(bpms, weights, min_bpm=60, max_bpm=180):
# сворачиваем все BPM в диапазон, умножая/деля на 2
folded = {}
for bpm, w in zip(bpms, weights):
while bpm < min_bpm:
bpm *= 2
while bpm > max_bpm:
bpm /= 2
bpm = round(bpm)
folded[bpm] = folded.get(bpm, 0) + w
# мягкое предпочтение: добавляем вес для типичных BPM (120-130)
for bpm in folded:
if 120 <= bpm <= 130:
folded[bpm] *= 1.2
return max(folded, key=folded.get)Медианное сглаживание по последним 5-10 измерениям даёт стабильный BPM без дёрганий.
Для точной расстановки ударов используем динамическое программирование (Ellis, 2007). Награждаем попадание в onset и штрафуем отклонение от равномерного интервала:
def beat_grid(onsets, period, penalty=0.1):
# onsets: массив вероятностей ударов (0-1)
# period: период в кадрах
T = len(onsets)
score = np.zeros(T)
back = np.zeros(T, dtype=int)
for t in range(1, T):
# ищем лучший предыдущий удар с учётом периода
candidates = np.arange(max(0, t-2*period), t)
scores = score[candidates] + onsets[t] - penalty * abs(t - candidates - period)
best = np.argmax(scores)
score[t] = scores[best]
back[t] = candidates[best]
# восстанавливаем путь
beats = []
t = np.argmax(score)
while t > 0:
beats.append(t)
t = back[t]
return sorted(beats)Результат — сетка, которая не дрейфует.
Тональность — это распределение энергии по 12 нотным классам. Используем хромограмму и корреляцию с эталонными профилями.
Сворачиваем спектр (примерно 55–2000 Гц) в 12 нотных классов, накапливая с экспоненциальным забыванием (~8 секунд памяти):
def chromagram(mag, sr, n_fft, memory=8.0, hop_length=512):
# mag: STFT магнитуда (частоты x время)
# отображаем частоты в ноты (A=0, A#=1, ...)
freqs = np.fft.rfftfreq(n_fft, 1/sr)
# нота = 12 * log2(freq / 440) + 69 (A4=440)
note_indices = np.round(12 * np.log2(freqs / 440) + 69).astype(int) % 12
chroma = np.zeros((12, mag.shape[1]))
for i, note in enumerate(note_indices):
chroma[note] += mag[i]
# накопление с забыванием
alpha = 1 - hop_length / (memory * sr)
accumulated = np.zeros(12)
for t in range(chroma.shape[1]):
accumulated = alpha * accumulated + (1 - alpha) * chroma[:, t]
chroma[:, t] = accumulated
return chromaПолучаем усреднённый вектор chroma — распределение нот за последние 8 секунд.
Сравниваем хромограмму с 24 эталонными профилями (12 мажорных, 12 минорных) — берём из исследований Крумхансла (1990). Профиль — это вес каждой ноты для данной тональности. Побеждает максимальная корреляция:
def krumhansl_key(chroma, profiles):
# profiles: dict {key: np.array(12)}
best_key = None
best_corr = -np.inf
for key, profile in profiles.items():
# сдвигаем хромограмму так, чтобы тоника совпала с C
for shift in range(12):
rotated = np.roll(chroma, -shift)
corr = np.corrcoef(rotated, profile)[0,1]
if corr > best_corr:
best_corr = corr
best_key = key
return best_keyМожно отобразить в систему Камелота для гармонического микширования. Этот подход восходит к libKeyFinder.
Метод не идеален: атональная или плотная гармония размывает хромограмму — тональность менее точна. Если трек модулирует, алгоритм покажет доминирующую. Слабые ударные или рубато дают мало onsets — BPM может плавать.
Соберите свой анализатор на Python: возьмите любой WAV-файл (22050 Гц, моно), примените функции выше и выведите BPM и тональность. Экспериментируйте с параметрами (размер окна, память хромограммы). Готовый пайплайн работает в приложении BeatScope для macOS/iPhone (бесплатно, офлайн): https://beatscope.pro. Вопросы по DSP — в комментарии!
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →