muscriptor-large-fork / plus /articulation.py
Nokia123543's picture
PLUS: артикуляция (легато/стакато) и измеренные концы нот
92c6d6b verified
Raw History Blame Contribute Delete
7.78 kB
"""Артикуляция: настоящий конец ноты и легато/стакато вместо условных длительностей.
Модель даёт онсет и грубый конец. Для профессионального звучания нужно другое:
конец берётся из записи (где реально упала энергия), а артикуляция — из
отношения длительности к паузе до следующей ноты того же голоса.
Термины:
legato — нота дожимается до следующего онсета (пауза ≤ 30 мс);
portato — пауза 30..150 мс, конец по записи;
staccato — нота короче половины шага, конец обрезается по записи (не более 60% шага);
tenuto — пауза больше половины шага, конец по записи.
Звук не синтезируется заново: меняются только начала/концы нот, высоты и
инструменты остаются модельные.
"""
import math
from collections import defaultdict
import numpy
FLOOR_DB = 14.0 # насколько энергия должна упасть от пика, чтобы считать ноту законченной
FRAME = 0.010 # 10 мс
LEGATO_GAP = 0.030
PORTATO_GAP = 0.150
STACCATO_SHARE = 0.60
MAX_TAIL = 6.0
def envelope(audio_path, frame=FRAME):
"""RMS-огибающая записи по кадрам."""
import velocity
samples, rate = velocity.decode_audio(audio_path)
step = max(16, int(frame * rate))
count = max(1, len(samples) // step)
trimmed = samples[:count * step].reshape(count, step).astype("float64")
rms = numpy.sqrt(numpy.mean(trimmed ** 2, axis=1) + 1e-20)
return 20.0 * numpy.log10(rms + 1e-12), frame
def band_end(segment, rate, midi, hop=0.010, win=None, drop_db=18.0):
"""Конец ноты по спаду энергии в полосе её основного тона."""
f0 = 440.0 * 2 ** ((midi - 69) / 12.0)
if win is None:
win = min(0.25, max(0.040, 6.0 / f0)) # 41 Гц требует ~0.15 с
low, high = f0 * 0.92, f0 * 1.08
hop_n, win_n = int(hop * rate), max(256, int(win * rate))
if len(segment) < win_n:
return None
frames = 1 + (len(segment) - win_n) // hop_n
if frames < 3:
return None
window = numpy.hanning(win_n)
band = numpy.zeros(frames)
freqs = numpy.fft.rfftfreq(win_n, 1.0 / rate)
mask = (freqs >= low) & (freqs <= high)
if not mask.any():
return None
for index in range(frames):
piece = segment[index * hop_n:index * hop_n + win_n] * window
spectrum = numpy.abs(numpy.fft.rfft(piece))
band[index] = spectrum[mask].sum()
if band.max() <= 0:
return None
peak = int(numpy.argmax(band))
threshold = band[peak] * (10 ** (-drop_db / 20.0))
for index in range(peak + 1, frames):
if band[index] <= threshold:
return (index + 1) * hop
return frames * hop
def measure_end(note, env, next_start=None, frame=FRAME):
"""Конец по падению энергии от пика, но не дальше следующей ноты."""
levels, _ = env
start = max(0, int(note["start"] / frame))
if start >= len(levels):
return None
limit = len(levels)
if next_start is not None:
limit = min(limit, max(start + 1, int(next_start / frame)))
else:
limit = min(limit, start + int(MAX_TAIL / frame))
window = levels[start:limit]
if not len(window):
return None
peak_index = int(numpy.argmax(window))
peak = window[peak_index]
threshold = peak - FLOOR_DB
end_index = len(window) - 1
for offset in range(peak_index + 1, len(window)):
if window[offset] <= threshold:
end_index = offset
break
return (start + end_index + 1) * frame
def refine(notes, audio_path, min_duration=0.045):
"""Пересчитать концы и расставить артикуляцию. Возвращает (notes, stats)."""
env = envelope(audio_path)
import velocity
segments, rate = velocity.decode_audio(audio_path)
by_voice = defaultdict(list)
for note in notes:
by_voice[note.get("instrument", "?")].append(note)
for voice in by_voice.values():
voice.sort(key=lambda n: n["start"])
counts = defaultdict(int)
for voice in by_voice.values():
onsets = []
for note in voice: # аккорд = один онсет, а не очередь нот
if not onsets or note["start"] - onsets[-1] > 0.030:
onsets.append(note["start"])
for index, note in enumerate(voice):
later = [o for o in onsets if o > note["start"] + 0.030]
next_start = later[0] if later else None
nominal = note.get("end", note["start"] + 0.1)
measured = None
if segments is not None and len(segments):
start_n = max(0, int(note["start"] * rate))
cap = next_start if next_start is not None else note["start"] + MAX_TAIL
pad = 0.0 if next_start is not None else 0.2
stop_n = min(len(segments), int(cap * rate) + int(pad * rate))
piece = segments[start_n:stop_n]
if len(piece) > 64:
tail = band_end(piece, rate, note["pitch"])
if tail:
measured = note["start"] + tail
if measured is None:
measured = measure_end(note, env, next_start)
nominal_len = nominal - note["start"]
if measured is not None and nominal_len > 0.02:
if (measured - note["start"]) < 0.5 * nominal_len:
measured = None # явно недомер, верим модели
elif next_start is not None and measured > next_start:
measured = next_start
end = measured if measured is not None else nominal
end = max(end, note["start"] + min_duration)
note["measured_end"] = round(end, 3)
step = (next_start - note["start"]) if next_start else None
gap = (next_start - end) if next_start else None
if step is None:
note["articulation"] = "natural"
elif gap is not None and gap <= LEGATO_GAP:
note["articulation"] = "legato"
end = next_start - 0.005
elif step and (end - note["start"]) >= 0.9 * step:
note["articulation"] = "tenuto"
elif gap is not None and gap > 0.5 * step:
note["articulation"] = "staccato"
end = note["start"] + max(min_duration, min(end - note["start"],
STACCATO_SHARE * step))
else:
note["articulation"] = "portato"
counts[note["articulation"]] += 1
note["end"] = round(max(end, note["start"] + min_duration), 3)
stats = {
"notes": len(notes),
"legato": counts.get("legato", 0),
"portato": counts.get("portato", 0),
"staccato": counts.get("staccato", 0),
"tenuto": counts.get("tenuto", 0),
}
if notes:
lengths = [n["end"] - n["start"] for n in notes]
stats["median_length"] = round(float(numpy.median(lengths)), 3)
stats["mean_length"] = round(float(numpy.mean(lengths)), 3)
return notes, stats