Download plus/velocity.py from Nokia123543/muscriptor-large-fork: direct link, hf CLI and curl.
- Browser
- Download file 8.52 kB
-
https://huggingface.co/Nokia123543/muscriptor-large-fork/resolve/main/plus/velocity.py
- Command line
-
hf download hf://Nokia123543/muscriptor-large-fork/plus/velocity.py
-
curl -L -o velocity.py https://huggingface.co/Nokia123543/muscriptor-large-fork/resolve/main/plus/velocity.py
8.52 kB
| """Живая громкость вместо константы 100. | |
| MuScriptor предсказывает только «нота началась», а velocity в MIDI пишет | |
| одну и ту же — 100. Здесь громкость берётся из самой записи: для каждой ноты | |
| меряется RMS и пик в окне 250 мс от атаки, раскладывается по перцентилям | |
| и превращается в velocity 1..124. | |
| Ключевое: файл не пересочиняется. Меняются ТОЛЬКО байты velocity у note_on, | |
| поэтому каналы, программа, темп, метки и всё прочее остаётся как было. | |
| Проверка: python plus/velocity.py audio.wav in.mid out.mid | |
| """ | |
| import math | |
| import struct | |
| import sys | |
| # ---------- разбор и правка SMF ---------- | |
| def _vlq(data, index): | |
| value, shift = 0, 0 | |
| while True: | |
| byte = data[index] | |
| index += 1 | |
| value = (value << 7) | (byte & 0x7F) | |
| if not byte & 0x80: | |
| return value, index | |
| shift += 7 | |
| if shift > 28: | |
| raise ValueError("битый delta") | |
| def scan_midi(data): | |
| """Вернуть ноты и места, где лежит их velocity.""" | |
| if data[:4] != b"MThd": | |
| raise ValueError("это не MIDI") | |
| header_len = struct.unpack(">I", data[4:8])[0] | |
| division = struct.unpack(">H", data[8 + header_len - 2:8 + header_len])[0] | |
| ticks_per_beat = division if division & 0x8000 == 0 else 480 | |
| notes = [] | |
| tempos = [(0, 500000)] | |
| index = 8 + header_len | |
| while index + 8 <= len(data): | |
| chunk = data[index:index + 4] | |
| length = struct.unpack(">I", data[index + 4:index + 8])[0] | |
| body_start, body_end = index + 8, index + 8 + length | |
| if chunk != b"MTrk": | |
| index = body_end | |
| continue | |
| i, tick, running = body_start, 0, None | |
| open_notes = {} | |
| while i < body_end: | |
| delta, i = _vlq(data, i) | |
| tick += delta | |
| status = data[i] | |
| if status < 0x80: | |
| if running is None: | |
| raise ValueError("running status без статуса") | |
| status = running | |
| else: | |
| i += 1 | |
| if status < 0xF0: | |
| running = status | |
| kind = status & 0xF0 | |
| if status == 0xFF: | |
| meta_type = data[i] | |
| meta_len, i = _vlq(data, i + 1) | |
| if meta_type == 0x51 and meta_len >= 3: | |
| tempos.append((tick, int.from_bytes(data[i:i + 3], "big"))) | |
| i += meta_len | |
| elif status in (0xF0, 0xF7): | |
| meta_len, i = _vlq(data, i) | |
| i += meta_len | |
| elif kind in (0x80, 0x90): | |
| pitch, velocity = data[i], data[i + 1] | |
| key = (status & 0x0F, pitch) | |
| if kind == 0x90 and velocity > 0: | |
| open_notes[key] = len(notes) | |
| notes.append({"tick": tick, "pitch": pitch, "channel": status & 0x0F, | |
| "velocity_at": i + 1, "velocity": velocity, | |
| "track": body_start}) | |
| elif key in open_notes: | |
| notes[open_notes.pop(key)]["end_tick"] = tick | |
| i += 2 | |
| elif kind in (0xA0, 0xB0, 0xE0): | |
| i += 2 | |
| elif kind in (0xC0, 0xD0): | |
| i += 1 | |
| else: | |
| raise ValueError("неизвестный статус 0x%02X" % status) | |
| index = body_end | |
| for note in notes: | |
| note.setdefault("end_tick", note["tick"] + ticks_per_beat // 4) | |
| tempos.sort() | |
| notes.sort(key=lambda n: n["tick"]) | |
| for note in notes: | |
| note["start"] = ticks_to_seconds(note["tick"], tempos, ticks_per_beat) | |
| note["end"] = ticks_to_seconds(note["end_tick"], tempos, ticks_per_beat) | |
| return notes | |
| def ticks_to_seconds(tick, tempos, ticks_per_beat): | |
| seconds, previous_tick, micros = 0.0, 0, 500000 | |
| for tempo_tick, tempo_micros in tempos: | |
| if tick <= tempo_tick: | |
| break | |
| seconds += (tempo_tick - previous_tick) / ticks_per_beat * micros / 1_000_000.0 | |
| previous_tick, micros = tempo_tick, tempo_micros | |
| seconds += (tick - previous_tick) / ticks_per_beat * micros / 1_000_000.0 | |
| return seconds | |
| # ---------- громкость из записи ---------- | |
| def decode_audio(path): | |
| import numpy | |
| try: | |
| import soundfile | |
| samples, rate = soundfile.read(path, dtype="float32", always_2d=True) | |
| return samples.mean(axis=1), rate | |
| except Exception: | |
| pass | |
| try: | |
| import librosa | |
| samples, rate = librosa.load(path, sr=None, mono=True) | |
| return numpy.asarray(samples, dtype="float32"), rate | |
| except Exception as problem: | |
| raise RuntimeError("не читается аудио: %s" % problem) | |
| def measure(notes, samples, rate, window=0.25): | |
| import numpy | |
| window_n = max(16, int(window * rate)) | |
| levels, attacks = [], [] | |
| for note in notes: | |
| start = int(note["start"] * rate) | |
| if start >= len(samples): | |
| continue | |
| chunk = samples[start:start + window_n] | |
| if len(chunk) < 16: | |
| continue | |
| rms = float(numpy.sqrt(numpy.mean(chunk.astype("float64") ** 2))) | |
| peak = float(numpy.max(numpy.abs(chunk))) | |
| if rms <= 1e-7: | |
| continue | |
| levels.append(20.0 * math.log10(rms)) | |
| attacks.append(max(1e-6, peak / rms)) | |
| note["_level"] = levels[-1] | |
| note["_attack"] = attacks[-1] | |
| return levels, attacks | |
| def to_velocity(notes): | |
| import numpy | |
| levels = numpy.array([n["_level"] for n in notes]) | |
| attacks = numpy.array([n["_attack"] for n in notes]) | |
| quiet = numpy.percentile(levels, 4) | |
| loud = numpy.percentile(levels, 99.7) | |
| span = max(0.5, loud - quiet) | |
| low_attack = numpy.percentile(attacks, 10) | |
| high_attack = numpy.percentile(attacks, 90) | |
| attack_span = max(0.05, high_attack - low_attack) | |
| for note in notes: | |
| share = min(1.0, max(0.0, (note["_level"] - quiet) / span)) | |
| accent = min(1.0, max(0.0, (note["_attack"] - low_attack) / attack_span)) | |
| shaped = (share ** 0.65) * (1.0 + 0.18 * accent) | |
| note["_new"] = int(round(1 + min(1.0, shaped) * 123)) | |
| return [n["_new"] for n in notes] | |
| # ---------- главное ---------- | |
| def apply(audio_path, midi_bytes): | |
| """Вернуть MIDI с измеренной громкостью и краткую статистику.""" | |
| notes = scan_midi(midi_bytes) | |
| if not notes: | |
| return midi_bytes, {"notes": 0} | |
| samples, rate = decode_audio(audio_path) | |
| measured = [n for n in notes if n.get("_level") is not None] or notes | |
| measure(notes, samples, rate) | |
| measured = [n for n in notes if n.get("_level") is not None] | |
| if not measured: | |
| return midi_bytes, {"notes": len(notes), "measured": 0} | |
| velocities = to_velocity(measured) | |
| fallback = int(round(sum(velocities) / len(velocities))) | |
| patched = bytearray(midi_bytes) | |
| silent = 0 | |
| for note in notes: | |
| if note.get("_new") is None: | |
| note["_new"] = fallback # окно попало в тишину | |
| silent += 1 | |
| patched[note["velocity_at"]] = note["_new"] | |
| stats = { | |
| "notes": len(notes), | |
| "measured": len(measured), | |
| "velocity_min": min(velocities), | |
| "velocity_max": max(velocities), | |
| "velocity_mean": round(sum(velocities) / len(velocities), 1), | |
| "distinct": len({n["_new"] for n in notes}), | |
| "silent": silent, | |
| "duration_s": round(notes[-1]["end"], 2), | |
| } | |
| return bytes(patched), stats | |
| def main(argv=None): | |
| argv = argv or sys.argv[1:] | |
| if len(argv) < 3: | |
| print(__doc__) | |
| return 2 | |
| audio_path, midi_path, out_path = argv[:3] | |
| with open(midi_path, "rb") as handle: | |
| original = handle.read() | |
| patched, stats = apply(audio_path, original) | |
| with open(out_path, "wb") as handle: | |
| handle.write(patched) | |
| print("нот: %(notes)d, измерено: %(measured)d | velocity %(velocity_min)d..%(velocity_max)d" | |
| " (среднее %(velocity_mean).1f, разных значений %(distinct)d)" % stats) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |