File size: 3,642 Bytes
1295d4a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 | """Проверка кандидатов по аудио: есть ли в записи энергия на этой высоте.
Модель с узким conditioning начинает выдумывать партии (на тесте 400 нот баса
на миксе, где бас есть, но не в таких высотах). Поэтому каждый кандидат
проверяется по звуку: в окне от атаки считается энергия на основной частоте
(Гёрцель) и сравнивается с медианной энергией по соседним полутонам. Нота
выживает, если её тон реально выделяется.
"""
import math
import wave
import numpy
def load_wav(path):
with wave.open(path, "rb") as handle:
rate = handle.getframerate()
channels = handle.getnchannels()
width = handle.getsampwidth()
frames = handle.readframes(handle.getnframes())
if width != 2:
raise ValueError("поддерживается 16-битный PCM")
data = numpy.frombuffer(frames, dtype="<i2").astype("float32") / 32768.0
if channels > 1:
data = data.reshape(-1, channels).mean(axis=1)
return data, rate
def goertzel(samples, rate, freq):
"""Энергия на одной частоте без полного FFT."""
n = len(samples)
if n < 32 or freq <= 0:
return 0.0
k = int(0.5 + n * freq / rate)
omega = 2.0 * math.pi * k / n
coeff = 2.0 * math.cos(omega)
s_prev = s_prev2 = 0.0
for value in samples:
s = value + coeff * s_prev - s_prev2
s_prev2, s_prev = s_prev, s
power = s_prev2 ** 2 + s_prev ** 2 - coeff * s_prev * s_prev2
return max(0.0, power) / (n * n)
def pitch_energy(samples, rate, midi):
freq = 440.0 * 2 ** ((midi - 69) / 12.0)
return goertzel(samples, rate, freq) + goertzel(samples, rate, freq * 2)
def verify(notes, audio_path, window=0.25, neighbours=3, threshold=2.0):
"""Отметить ноты как подтверждённые записью. Возвращает (kept, dropped, stats)."""
samples, rate = load_wav(audio_path)
length = max(64, int(window * rate))
kept, dropped = [], []
for note in notes:
if note.get("instrument") == "drums" or note.get("is_drum"):
note["energy_ratio"] = None # у барабанов нет основного тона
kept.append(note)
continue
start = int(note["start"] * rate)
if start >= len(samples):
dropped.append(note)
continue
chunk = samples[start:start + length]
if len(chunk) < 64:
dropped.append(note)
continue
mine = pitch_energy(chunk, rate, note["pitch"])
reference = []
for step in range(1, neighbours + 1):
for direction in (-1, 1):
reference.append(pitch_energy(chunk, rate, note["pitch"] + direction * step))
floor = float(numpy.median(reference)) + 1e-9
ratio = mine / floor
note["energy_ratio"] = round(ratio, 2)
if ratio >= threshold or note.get("support", 1) >= 2:
kept.append(note)
else:
dropped.append(note)
stats = {
"kept": len(kept),
"dropped": len(dropped),
"threshold": threshold,
}
pitched = [n["energy_ratio"] for n in kept if n.get("energy_ratio") is not None]
if pitched:
stats["median_ratio"] = round(float(numpy.median(pitched)), 2)
return kept, dropped, stats
|