Download plus/articulation.py from Nokia123543/muscriptor-large-fork: direct link, hf CLI and curl.
- Browser
- Download file 7.78 kB
-
https://huggingface.co/Nokia123543/muscriptor-large-fork/resolve/main/plus/articulation.py
- Command line
-
hf download hf://Nokia123543/muscriptor-large-fork/plus/articulation.py
-
curl -L -o articulation.py https://huggingface.co/Nokia123543/muscriptor-large-fork/resolve/main/plus/articulation.py
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 | |