"""Артикуляция: настоящий конец ноты и легато/стакато вместо условных длительностей. Модель даёт онсет и грубый конец. Для профессионального звучания нужно другое: конец берётся из записи (где реально упала энергия), а артикуляция — из отношения длительности к паузе до следующей ноты того же голоса. Термины: 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