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