#!/usr/bin/env python3 """Dynamics for MuScriptor output: loudness becomes velocity. MuScriptor Large finds pitch, onset, duration and instrument, but it does not predict velocity, so every note in its MIDI arrives at the same strength and a forte passage reads exactly like a piano one. The strength is measurable in the recording itself, note by note, at the onset the model already placed: what is missing is not a better model but a measurement. So this takes the audio and the model's MIDI, measures each note, and writes a MIDI whose velocities follow the performance. Nothing is retrained and nothing loops: one pass over the notes, progress printed in tens of percent. Usage: python3 muscriptor_dynamics.py song.wav notes.mid out.mid python3 muscriptor_dynamics.py --selftest """ from __future__ import annotations __version__ = "1.1" import math import os import struct import sys import wave import numpy as np # How far into a note the loudness is measured. A piano hammer peaks within a # few tens of milliseconds; a bowed note swells for longer. A quarter of a second # covers both without wandering into the next note in a fast passage. ONSET_WINDOW_S = 0.25 # The dynamic range mapped onto 1..127, in decibels. Everything at or below the # floor is a whisper, everything at or above the ceiling is a full strike. FLOOR_DB = -34.0 CEIL_DB = 0.0 # Gamma below 1 lifts quiet notes so a soft passage stays playable rather than # collapsing into single-digit velocities. CURVE = 0.65 # --------------------------------------------------------------------------- # # audio # --------------------------------------------------------------------------- # def load_audio(path): """Mono float32 samples and the sample rate. WAV natively, MP3/other via ffmpeg.""" if path.lower().endswith(".wav"): with wave.open(path, "rb") as w: channels = w.getnchannels() width = w.getsampwidth() rate = w.getframerate() frames = w.readframes(w.getnframes()) else: import subprocess import tempfile with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: out = tmp.name try: subprocess.run( ["ffmpeg", "-v", "error", "-y", "-i", path, "-ac", "1", "-ar", "22050", "-f", "wav", out], check=True, ) with wave.open(out, "rb") as w: channels = w.getnchannels() width = w.getsampwidth() rate = w.getframerate() frames = w.readframes(w.getnframes()) finally: try: os.unlink(out) except OSError: pass if width == 2: data = np.frombuffer(frames, dtype=" 1: data = data.reshape(-1, channels).mean(axis=1) return data, rate # --------------------------------------------------------------------------- # # MIDI reading and writing (Standard MIDI File, the parts a transcription uses) # --------------------------------------------------------------------------- # MARKER = b"muscriptor:bar_offset=" def _vlq(data, i): value = 0 while True: byte = data[i] i += 1 value = (value << 7) | (byte & 0x7F) if not byte & 0x80: return value, i def _put_vlq(out, value): chunk = [value & 0x7F] value >>= 7 while value: chunk.append((value & 0x7F) | 0x80) value >>= 7 out.extend(reversed(chunk)) def read_midi(path): """Notes as (start_s, dur_s, pitch, channel) plus the raw tempo map.""" raw = open(path, "rb").read() if not raw.startswith(b"MThd"): raise SystemExit("not a MIDI file: " + path) division = struct.unpack(">H", raw[12:14])[0] if division & 0x8000: raise SystemExit("SMPTE timecode MIDI is not supported") pos = 14 tempo = [(0, 500000)] sounding = {} notes = [] while pos + 8 <= len(raw) and raw[pos:pos + 4] == b"MTrk": length = struct.unpack(">I", raw[pos + 4:pos + 8])[0] body = raw[pos + 8:pos + 8 + length] pos += 8 + length tick = 0 status = None i = 0 while i < len(body): delta, i = _vlq(body, i) tick += delta if i >= len(body): break byte = body[i] if byte == 0xFF: i += 1 meta = body[i] i += 1 size, i = _vlq(body, i) if meta == 0x51 and size == 3: tempo.append((tick, (body[i] << 16) | (body[i + 1] << 8) | body[i + 2])) i += size status = None elif byte in (0xF0, 0xF7): i += 1 size, i = _vlq(body, i) i += size status = None elif byte & 0x80: status = byte i += 1 high = status & 0xF0 channel = status & 0x0F count = 1 if high in (0xC0, 0xD0) else 2 args = body[i:i + count] i += count if high == 0x90 and args[1] > 0: sounding.setdefault((channel, args[0]), []).append(tick) elif high == 0x80 or (high == 0x90 and args[1] == 0): stack = sounding.get((channel, args[0])) if stack: start = stack.pop(0) notes.append([start, tick - start, args[0], channel]) else: if status is None: break high = status & 0xF0 channel = status & 0x0F count = 1 if high in (0xC0, 0xD0) else 2 args = body[i:i + count] i += count if high == 0x90 and args[1] > 0: sounding.setdefault((channel, args[0]), []).append(tick) elif high == 0x80 or (high == 0x90 and args[1] == 0): stack = sounding.get((channel, args[0])) if stack: start = stack.pop(0) notes.append([start, tick - start, args[0], channel]) for (channel, pitch), stack in sounding.items(): for start in stack: notes.append([start, 0, pitch, channel]) tempo.sort(key=lambda item: item[0]) def to_seconds(tick): seconds = 0.0 last = 0 us_per_quarter = 500000 for at, us in tempo: if at > tick: break seconds += (at - last) / division * (us_per_quarter / 1000000.0) last = at us_per_quarter = us seconds += (tick - last) / division * (us_per_quarter / 1000000.0) return seconds converted = [(to_seconds(s), max(0.05, to_seconds(s + d) - to_seconds(s)), p, c) for s, d, p, c in notes] converted.sort(key=lambda item: item[0]) return converted, tempo, division def write_midi(path, notes, bpm=120): """A single-track MIDI at a constant tempo, velocities as measured. The bar-offset marker the box writes is deliberately not reproduced: it is a text event that anything rendering MIDI text shows as garbage, and the alignment it carries is only meaningful to the box's own auralizer. """ division = 480 us_per_quarter = int(round(60000000.0 / max(1.0, bpm))) events = [(0, b"\xff\x51\x03" + us_per_quarter.to_bytes(3, "big"))] events.append((0, b"\xff\x03\x08pianoify")) for start, dur, pitch, channel, velocity in notes: on_tick = int(round(start * division * 1000000.0 / us_per_quarter)) off_tick = int(round((start + dur) * division * 1000000.0 / us_per_quarter)) channel = max(0, min(15, channel)) events.append((on_tick, bytes([0x90 | channel, pitch, max(1, min(127, velocity))]))) events.append((off_tick, bytes([0x80 | channel, pitch, 0]))) events.sort(key=lambda item: (item[0], item[1][0] & 0xF0 == 0x80)) track = bytearray() last = 0 for tick, payload in events: _put_vlq(track, tick - last) track.extend(payload) last = tick _put_vlq(track, division * 2) track.extend(b"\xff\x2f\x00") blob = bytearray(b"MThd" + struct.pack(">IHHH", 6, 0, 1, division)) blob += b"MTrk" + struct.pack(">I", len(track)) + track with open(path, "wb") as f: f.write(blob) return path # --------------------------------------------------------------------------- # # the measurement # --------------------------------------------------------------------------- # def measure(notes, samples, rate, progress=None): """Velocity per note, from the loudness and the attack at its onset. Two things make a note read as fortissimo rather than piano: how loud it is overall, and how hard it starts. A drummer and a pianist both hit harder for an accent, so the attack-to-body ratio is blended in — it is what separates a struck note from a sustained one at the same level. """ levels = np.zeros(len(notes)) attacks = np.zeros(len(notes)) window = max(1, int(ONSET_WINDOW_S * rate)) total = max(1, len(notes)) for index, (start, dur, pitch, _channel) in enumerate(notes): first = int(max(0, round(start * rate))) span = min(window, max(1, int(max(0.03, min(dur, ONSET_WINDOW_S)) * rate))) chunk = samples[first:first + span] if chunk.size == 0: levels[index] = -120.0 attacks[index] = 0.0 else: rms = float(np.sqrt(np.mean(chunk * chunk)) + 1e-9) peak = float(np.max(np.abs(chunk)) + 1e-9) levels[index] = 20.0 * math.log10(rms) attacks[index] = peak / rms if progress and index % max(1, total // 10) == 0: progress("измеряю ноты: %d%%" % (100 * index // total)) if not len(notes): return [] loud = np.percentile(levels, 99.7) quiet = np.percentile(levels, 4) if loud - quiet < 6.0: # a flat piece: keep gentle dynamics loud = quiet + 6.0 attack_med = float(np.median(attacks)) or 1.0 attack_span = max(1e-6, float(np.percentile(attacks, 90) - np.percentile(attacks, 10))) velocities = [] for index in range(len(notes)): share = (levels[index] - quiet) / (loud - quiet) share = min(1.0, max(0.0, share)) accent = (attacks[index] - (attack_med - attack_span / 2)) / attack_span accent = min(1.0, max(-1.0, accent)) # Multiplicative, not additive: an accent should sharpen the note's own # level rather than lift every loud note onto the same ceiling. shaped = min(1.0, max(0.0, share ** CURVE * (1.0 + 0.18 * accent))) # A floor of 12: velocity 1 is inaudible on every synth, so the quietest # measured note still has to be playable, and the loudest keeps headroom. velocities.append(int(round(12 + shaped * 112))) return velocities def histogram(velocities): buckets = [0] * 6 for velocity in velocities: buckets[min(5, velocity * 6 // 128)] += 1 return buckets def run(audio_path, midi_path, out_path): steps = 5 def tell(percent, what): print("[%3d%%] %s" % (percent, what), flush=True) tell(100 * 0 // steps, "читаю аудио " + os.path.basename(audio_path)) samples, rate = load_audio(audio_path) tell(100 * 1 // steps, "аудио: %.1f с, %d Гц" % (len(samples) / rate, rate)) notes, tempo, division = read_midi(midi_path) if not notes: raise SystemExit("no notes in " + midi_path) tell(100 * 2 // steps, "MIDI: %d нот, первый темп %d мкс/четверть" % (len(notes), tempo[0][1])) def progress(message): print(" " + message, flush=True) velocities = measure(notes, samples, rate, progress) tell(100 * 3 // steps, "динамика: velocity %d..%d, медиана %d" % (min(velocities), max(velocities), int(np.median(velocities)))) bpm = 60000000.0 / tempo[0][1] out_notes = [(start, dur, pitch, channel, velocities[i]) for i, (start, dur, pitch, channel) in enumerate(notes)] write_midi(out_path, out_notes, bpm) tell(100 * 4 // steps, "записан " + os.path.basename(out_path)) buckets = histogram(velocities) labels = ["pp", "p", "mp", "mf", "f", "ff"] print(" динамические оттенки: " + ", ".join(labels[i] + "=" + str(buckets[i]) for i in range(6)), flush=True) tell(100, "готово") return velocities # --------------------------------------------------------------------------- # # self-test: the measurement has to follow the loudness that is really there # --------------------------------------------------------------------------- # def selftest(): rate = 22050 amplitudes = [0.08, 0.5, 0.12, 0.45, 0.09, 0.55, 0.15, 0.4] pitches = [60, 62, 64, 65, 67, 69, 71, 72] note_seconds = 0.4 total = int(rate * (note_seconds * len(pitches) + 0.2)) signal = np.zeros(total, dtype=np.float32) for index, (amp, pitch) in enumerate(zip(amplitudes, pitches)): freq = 440.0 * 2 ** ((pitch - 69) / 12) length = int(rate * note_seconds) t = np.arange(length) / rate tone = sum((1.0 / k) * np.sin(2 * math.pi * freq * k * t) for k in (1, 2, 3)) tone *= np.exp(-6.0 * t) start = int(index * note_seconds * rate) signal[start:start + length] += (amp * tone).astype(np.float32) signal = np.clip(signal / max(1e-9, np.max(np.abs(signal))) * 0.95, -1, 1) here = os.path.dirname(os.path.abspath(__file__)) wav_path = os.path.join(here, "selftest_dynamics.wav") midi_path = os.path.join(here, "selftest_dynamics.mid") out_path = os.path.join(here, "selftest_dynamics_out.mid") with wave.open(wav_path, "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(rate) w.writeframes((signal * 32767).astype(" 20, "динамика не разделилась" assert correlation > 0.85, "velocity не следует за громкостью" assert all(1 <= v <= 127 for v in velocities), "velocity вне диапазона" print("self-test: OK", flush=True) for path in (wav_path, midi_path, out_path): try: os.unlink(path) except OSError: pass if __name__ == "__main__": if "--selftest" in sys.argv: selftest() elif len(sys.argv) == 4: run(sys.argv[1], sys.argv[2], sys.argv[3]) else: print(__doc__) raise SystemExit(2)