muscriptor-large-fork / plus /plus_transcribe.py
Nokia123543's picture
чистка: убрано упоминание другого движка
64a1ad5 verified
Raw History Blame Contribute Delete
24.9 kB
"""PLUS transcribe: пост-обработка MIDI от MuScriptor / Aria.
Стадии независимы и включаются флагами:
cleanup унисоны, фантомы, перекрытия, короткие ноты
quantize посадка на сетку файла (сила 0..1, свинг)
hands разделение на правую и левую руку
pedal вывод педали (CC64) и продление нот под ней
chords распознавание аккордов и подписи в MIDI
dynamics громкость из аудио (RMS + атака), баланс рук
report метрики, аномалии, JSON
CLI:
python plus_transcribe.py --midi in.mid --out out.mid --audio in.wav \
--stages cleanup,quantize,hands,pedal,chords,dynamics,report \
--quantize-strength 0.85 --report out.json
"""
from __future__ import annotations
import argparse
import math
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from plus_core import ( # noqa: E402
Note, Piece, ambiguity_of, dump_json, explained_fraction, load_midi,
pitch_name, save_midi, step, tempo_from_onsets,
)
__version__ = "1.0"
STAGES = ("cleanup", "quantize", "hands", "pedal", "chords", "dynamics", "report")
# ---------------------------------------------------------------- стадии ----
def stage_cleanup(piece: Piece, options: dict) -> dict:
"""Убирает дубли, фантомы по громкости и нефизичные длительности.
Три правила, каждое с отдельным счётчиком, чтобы отчёт был честным:
1) унисон: две ноты одного питча в пределах 45 мс и с перекрытием > 60%
считаются одной (остаётся более громкая, длительность — объединение);
2) фантом: нота короче 30 мс или громче чем на 25 ниже медианы ансамбля
при том же питче в пределах 200 мс;
3) обрезка: длительность не длиннее времени до следующей ноты того же
питча минус 10 мс (иначе в DAW нота глушит саму себя).
"""
min_dur = float(options.get("min_dur", 0.03))
unison_ms = float(options.get("unison_ms", 45.0))
result: list[Note] = []
removed_short = removed_ghost = merged = trimmed = 0
by_pitch: dict[int, list[Note]] = {}
for note in sorted(piece.notes, key=lambda n: (n.pitch, n.start)):
by_pitch.setdefault(note.pitch, []).append(note)
for pitch, group in by_pitch.items():
kept: list[Note] = []
for note in group:
if note.dur < min_dur:
removed_short += 1
continue
if kept:
last = kept[-1]
overlap = min(last.end, note.end) - max(last.start, note.start)
same_time = abs(note.start - last.start) <= unison_ms / 1000.0
if same_time or (overlap > 0.6 * min(last.dur, note.dur)):
if note.velocity > last.velocity:
kept[-1] = Note(last.start, max(last.end, note.end) - last.start,
pitch, note.velocity, note.channel, note.track)
else:
kept[-1] = Note(last.start, max(last.end, note.end) - last.start,
pitch, last.velocity, last.channel, last.track)
merged += 1
continue
kept.append(Note(**vars(note)))
# одиночная тихая короткая нота на редком питче — почти всегда фантом
if len(kept) <= 2:
global_median = float(np.median([n.velocity for n in piece.notes])) if piece.notes else 80.0
filtered = []
for note in kept:
if note.dur < 0.12 and note.velocity < global_median - 35:
removed_ghost += 1
continue
filtered.append(note)
kept = filtered
# фантомы: слишком тихие на фоне соседей того же питча
if len(kept) >= 5:
levels = np.array([n.velocity for n in kept], dtype=float)
floor = np.median(levels) - 25.0
pruned = []
for note in kept:
if note.velocity < floor and note.dur < 0.12:
removed_ghost += 1
continue
pruned.append(note)
kept = pruned
# обрезка до следующей ноты того же питча
for index, note in enumerate(kept[:-1]):
gap = kept[index + 1].start - note.start
if note.dur > gap:
note.dur = max(min_dur, gap - 0.01)
trimmed += 1
result.extend(kept)
result.sort(key=lambda n: (n.start, n.pitch))
piece.notes = result
return {"short_removed": removed_short, "ghosts_removed": removed_ghost,
"unisons_merged": merged, "trimmed": trimmed}
def stage_quantize(piece: Piece, options: dict) -> dict:
"""Посадка онсетов и длительностей на сетку.
Темп из файла часто врёт: модели пишут заголовок 120, а ноты ставят по
своей сетке. Поэтому сначала ищем темп, который ОБЪЯСНЯЕТ онсеты (файловый,
его кратные и скан), и подменяем заголовок, только если найденное объяснение
заметно лучше. Если даже лучший кандидат объясняет меньше 75 процентов
онсетов, квантование пропускается: ровнять по чужой сетке хуже, чем не
ровнять вовсе.
"""
division = int(options.get("division", 4))
strength = float(options.get("strength", 0.85))
swing = float(options.get("swing", 0.0))
forced = options.get("tempo")
file_bpm = float(piece.bpm or 120.0)
notes = piece.notes
if not notes:
return {"bpm": round(file_bpm, 2), "division": division, "strength": strength,
"swing": swing, "explained_before": 0.0, "explained_after": 0.0,
"notes_moved": 0, "corrected_tempo": False, "skipped": True,
"reason": "нет нот", "alternatives": []}
starts = piece.starts()
sample = starts if len(starts) <= 400 else starts[:: max(1, len(starts) // 400)]
file_fraction = explained_fraction(starts, file_bpm)
def score(candidate):
return explained_fraction(sample, candidate)
# Только простые отношения к темпу файла: плотный скан даёт произвольные
# темпы вида 108, которые «объясняют» сетку случайно.
factors = (1.0, 1.5, 2.0 / 3.0, 2.0, 0.5, 3.0, 1.0 / 3.0)
best_bpm, best_fraction = file_bpm, file_fraction
if forced is None and options.get("auto_tempo", True):
for factor in factors:
candidate = file_bpm * factor
if not (30 <= candidate <= 260):
continue
value = score(candidate)
if value > best_fraction + 1e-9:
best_bpm, best_fraction = candidate, value
if forced is not None:
best_bpm, best_fraction = float(forced), score(float(forced))
threshold = float(options.get("min_explained", 0.75))
if best_fraction < threshold:
return {"bpm": round(file_bpm, 2), "division": division, "strength": strength,
"swing": swing, "explained_before": round(file_fraction, 3),
"explained_after": round(file_fraction, 3), "notes_moved": 0,
"corrected_tempo": False, "skipped": True,
"reason": "сетка объясняет только %.2f онсетов (порог %.2f), тайминг не трогаю"
% (best_fraction, threshold),
"best_candidate_bpm": round(best_bpm, 2),
"alternatives": ambiguity_of(starts, best_bpm)}
corrected = abs(np.log2(best_bpm / file_bpm)) > 0.02
bpm = float(best_bpm)
beat = 60.0 / bpm
step_s = beat / division
fitted = 0
for note in notes:
target = round(note.start / step_s) * step_s
if swing and int(round(note.start / step_s)) % 2 == 1:
target += swing * step_s
moved = (target - note.start) * strength
note.start = max(0.0, note.start + moved)
if abs(moved) > 1e-6:
fitted += 1
length = round(note.dur / step_s) * step_s
if length > 0:
note.dur = max(1e-3, note.dur + (length - note.dur) * strength)
notes.sort(key=lambda n: (n.start, n.pitch))
if corrected:
# заголовок приводим к найденной сетке, иначе такты в DAW встанут не туда
piece.tempo_changes = [(0.0, bpm)]
after = explained_fraction(piece.starts(), bpm)
return {"bpm": round(bpm, 2), "division": division, "strength": strength,
"swing": swing, "explained_before": round(file_fraction, 3),
"explained_after": round(after, 3), "notes_moved": fitted,
"corrected_tempo": corrected, "skipped": False,
"alternatives": ambiguity_of(starts, bpm)}
def stage_hands(piece: Piece, options: dict) -> dict:
"""Делит ноты на правую и левую руку по стоимости перехода.
Жадный проход с двумя курсорами: нота идёт в ту руку, где ближе по питчу и
по времени, с запретом левой руке уходить выше правой больше чем на октаву.
Работает на одну дорожку, поэтому подходит и для фортепианных моделей,
которые пишут всё в один канал.
"""
if not piece.notes:
return {"left": 0, "right": 0}
split_pitch = int(options.get("split_pitch", 60))
notes = sorted(piece.notes, key=lambda n: (n.start, n.pitch))
left_last = right_last = None
left_cost = right_cost = 0.0
for note in notes:
def cost(last, side):
if last is None:
base = abs(note.pitch - (split_pitch - 7 if side == "left" else split_pitch + 7))
return base / 12.0 + 0.6
return abs(note.pitch - last.pitch) / 12.0 + min(1.0, (note.start - last.start) / 2.0)
to_left, to_right = cost(left_last, "left"), cost(right_last, "right")
if to_left < to_right:
note.track = "LH"
left_last, left_cost = note, left_cost + to_left
else:
note.track = "RH"
right_last, right_cost = note, right_cost + to_right
# левая рука не должна оказаться выше правой: меняем подозрительные пары
swapped = 0
rh = [n for n in notes if n.track == "RH"]
lh = [n for n in notes if n.track == "LH"]
if rh and lh:
rh_median = float(np.median([n.pitch for n in rh]))
for note in lh:
if note.pitch > rh_median + 12:
note.track = "RH"
swapped += 1
piece.notes.sort(key=lambda n: (n.start, n.pitch))
left = sum(1 for n in piece.notes if n.track == "LH")
return {"left": left, "right": len(piece.notes) - left, "raised_from_left": swapped}
def stage_pedal(piece: Piece, options: dict) -> dict:
"""Выводит педаль по смене гармонии: CC64 плюс продление нот.
Онсеты кластеризуются в аккордовые события (окно 60 мс). Педаль нажимается
на аккорде, где >= 2 нот, и отпускается, когда меняется набор высотных
классов или истекает max_hold. Продление ограничено следующей нотой того же
питча и концом окна, поэтому каши из наложенных нот не возникает.
"""
if not piece.notes:
return {"spans": 0, "extended": 0}
max_hold = float(options.get("max_hold", 1.6))
window = float(options.get("window", 0.06))
notes = sorted(piece.notes, key=lambda n: n.start)
clusters: list[list[Note]] = []
for note in notes:
if clusters and note.start - clusters[-1][0].start <= window:
clusters[-1].append(note)
else:
clusters.append([note])
spans: list[tuple[float, float]] = []
current: tuple[float, frozenset] | None = None
for index, cluster in enumerate(clusters):
classes = frozenset(n.pitch % 12 for n in cluster)
start_time = cluster[0].start
next_time = clusters[index + 1][0].start if index + 1 < len(clusters) else piece.duration + 0.4
if len(cluster) < 2:
if current is not None:
spans.append((current[0], start_time))
current = None
continue
if current is None:
current = (start_time, classes)
elif classes != current[1] or start_time - current[0] > max_hold:
spans.append((current[0], start_time))
current = (start_time, classes)
if index + 1 == len(clusters):
end_time = min(next_time, current[0] + max_hold)
spans.append((current[0], end_time))
current = None
if current is not None:
spans.append((current[0], min(piece.duration + 0.4, current[0] + max_hold)))
spans = [(a, b) for a, b in spans if b - a > 0.05]
by_pitch: dict[int, list[float]] = {}
for note in notes:
by_pitch.setdefault(note.pitch, []).append(note.start)
for starts in by_pitch.values():
starts.sort()
extended = 0
for note in notes:
for start, end in spans:
if not (start <= note.start < end):
continue
following = next((s for s in by_pitch[note.pitch] if s > note.start + 1e-4), None)
limit = end if following is None else min(end, following - 0.01)
if limit > note.end:
note.dur = limit - note.start
extended += 1
break
piece.meta["pedal_spans"] = [(round(a, 3), round(b, 3)) for a, b in spans]
return {"spans": len(spans), "extended": extended,
"median_hold": round(float(np.median([b - a for a, b in spans])), 3) if spans else 0.0}
CHORD_TEMPLATES = {
"": (0, 4, 7), "m": (0, 3, 7), "7": (0, 4, 7, 10), "m7": (0, 3, 7, 10),
"maj7": (0, 4, 7, 11), "dim": (0, 3, 6), "aug": (0, 4, 8), "sus4": (0, 5, 7),
"sus2": (0, 2, 7), "6": (0, 4, 7, 9), "m6": (0, 3, 7, 9),
}
def _chord_name(pitches: list[int]) -> str | None:
classes = sorted({p % 12 for p in pitches})
if len(classes) < 3:
return None
best = None
for root in classes:
relative = sorted((c - root) % 12 for c in classes)
for suffix, template in CHORD_TEMPLATES.items():
if len(template) != len(relative):
continue
if all(abs(a - b) <= 1 for a, b in zip(relative, template)):
score = sum(abs(a - b) for a, b in zip(relative, template))
if best is None or score < best[0]:
best = (score, pitch_name(root + 60)[:-1] + suffix)
return best[1] if best else None
def stage_chords(piece: Piece, options: dict) -> dict:
"""Аккорды по одновременным онсетам: список для отчёта и метки в piece.meta."""
window = float(options.get("window", 0.06))
notes = sorted(piece.notes, key=lambda n: n.start)
groups: list[list[Note]] = []
for note in notes:
if groups and note.start - groups[-1][0].start <= window:
groups[-1].append(note)
else:
groups.append([note])
timeline = []
named = 0
for group in groups:
pitches = [n.pitch for n in group]
if len(group) < 3:
continue
name = _chord_name(pitches)
if name:
named += 1
timeline.append({"time": round(group[0].start, 3), "size": len(group),
"pitches": sorted(pitches), "chord": name})
piece.meta["chords"] = timeline
return {"clusters": len(timeline), "named": named,
"first": timeline[:8] if timeline else []}
def stage_dynamics(piece: Piece, options: dict) -> dict:
"""Громкость из аудио: RMS и атака в окне от онсета, затем кривая в MIDI.
Velocity = 1 + shaped*123, где shaped это перцентильный share громкости,
поднятый в степень 0.65 и умноженный на акцент атаки. Для рук добавляется
баланс: левая на gain_left, правая на gain_right, чтобы форте в левой не
перекрывало мелодию.
"""
audio_path = options.get("audio")
if not audio_path or not os.path.isfile(audio_path) or not piece.notes:
return {"applied": False, "reason": "нет аудио"}
try:
import librosa
except Exception as exc:
return {"applied": False, "reason": "нет librosa: %s" % exc}
window = float(options.get("window", 0.25))
gain_left = float(options.get("gain_left", 0.88))
gain_right = float(options.get("gain_right", 1.0))
samples, rate = librosa.load(audio_path, sr=22050, mono=True)
hop = 128
frame = 512
rms = librosa.feature.rms(y=samples, frame_length=frame, hop_length=hop)[0]
frame_times = librosa.frames_to_time(np.arange(rms.size), sr=rate, hop_length=hop)
levels, attacks = [], []
for note in piece.notes:
index = int(np.searchsorted(frame_times, note.start))
span = rms[index: index + max(1, int(window * rate / hop))]
if span.size == 0:
levels.append(0.0)
attacks.append(1.0)
continue
peak = float(span.max())
mean = float(span.mean()) + 1e-9
levels.append(20.0 * math.log10(mean + 1e-9))
attacks.append(min(2.0, peak / mean))
levels = np.array(levels)
attacks = np.array(attacks)
quiet, loud = np.percentile(levels, 4), np.percentile(levels, 99.7)
share = np.clip((levels - quiet) / max(1e-6, loud - quiet), 0.0, 1.0)
shaped = np.power(share, 0.65) * (1.0 + 0.18 * (attacks - 1.0))
velocity = np.clip(15 + shaped * 103, 15, 118).astype(int)
for note, value in zip(piece.notes, velocity):
gain = gain_left if note.track == "LH" else gain_right
note.velocity = int(max(1, min(127, round(value * gain))))
piece.meta["dynamics"] = {"quiet_db": round(float(quiet), 2), "loud_db": round(float(loud), 2)}
spread = int(velocity.max() - velocity.min())
return {"applied": True, "velocity_min": int(velocity.min()),
"velocity_max": int(velocity.max()), "spread": spread,
"window": window, "gain_left": gain_left}
def stage_report(piece: Piece, options: dict) -> dict:
"""Метрики и аномалии: что чинить дальше, без догадок."""
notes = piece.notes
if not notes:
return {"empty": True}
starts = piece.starts()
durations = np.array([n.dur for n in notes])
pitches = piece.pitches()
velocities = piece.velocities()
span = max(1e-9, piece.duration)
overlaps = 0
by_pitch: dict[int, list[Note]] = {}
for note in sorted(notes, key=lambda n: (n.pitch, n.start)):
by_pitch.setdefault(note.pitch, []).append(note)
for group in by_pitch.values():
for a, b in zip(group, group[1:]):
if b.start < a.end - 1e-3:
overlaps += 1
density = np.array([np.sum((starts >= t) & (starts < t + 1.0)) for t in np.arange(0, span, 1.0)])
anomalies = []
if overlaps:
anomalies.append({"kind": "overlap", "count": overlaps})
if durations.min() < 0.02:
anomalies.append({"kind": "very_short_notes", "count": int((durations < 0.02).sum())})
if density.size and density.max() > 25:
anomalies.append({"kind": "density_spike", "per_second": int(density.max())})
if velocities.size and velocities.std() < 3:
anomalies.append({"kind": "flat_velocity", "std": round(float(velocities.std()), 2)})
octave_jumps = int(np.sum(np.abs(np.diff(np.sort(pitches))) > 24))
return {
"notes": len(notes), "duration": round(piece.duration, 3),
"notes_per_second": round(len(notes) / span, 2),
"pitch_range": [pitch_name(int(pitches.min())), pitch_name(int(pitches.max()))],
"velocity": [int(velocities.min()), int(velocities.max()),
round(float(velocities.mean()), 1)],
"duration_median": round(float(np.median(durations)), 3),
"overlaps": overlaps, "octave_leaps": octave_jumps,
"anomalies": anomalies,
"tracks": {name: sum(1 for n in notes if n.track == name)
for name in sorted({n.track for n in notes})},
}
HANDLERS = {
"cleanup": stage_cleanup,
"quantize": stage_quantize,
"hands": stage_hands,
"pedal": stage_pedal,
"chords": stage_chords,
"dynamics": stage_dynamics,
"report": stage_report,
}
def run_pipeline(piece: Piece, stages: list[str], options: dict) -> dict:
report: dict = {"version": __version__, "source": piece.source, "stages": {}}
for index, name in enumerate(stages, 1):
handler = HANDLERS.get(name)
if handler is None:
raise SystemExit("неизвестная стадия: " + name)
step(index, len(stages), name)
result = handler(piece, options)
report["stages"][name] = result
summary = ", ".join("%s=%s" % (k, v) for k, v in result.items()
if isinstance(v, (int, float, str)) and k != "first")
print(" " + (summary[:150] if summary else "готово"), flush=True)
report["stats"] = piece.stats()
return report
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="PLUS: пост-обработка MIDI транскрипции")
parser.add_argument("--midi", required=True, help="входной MIDI")
parser.add_argument("--out", help="куда писать MIDI (по умолчанию рядом, суффикс _plus)")
parser.add_argument("--audio", help="аудио для стадии dynamics")
parser.add_argument("--stages", default="cleanup,quantize,hands,pedal,dynamics,report")
parser.add_argument("--tempo", type=float, help="переопределить BPM")
parser.add_argument("--quantize-strength", type=float, default=0.85)
parser.add_argument("--division", type=int, default=4)
parser.add_argument("--swing", type=float, default=0.0)
parser.add_argument("--auto-tempo", action="store_true", help="подстроить темп в пределах 15%")
parser.add_argument("--split-pitch", type=int, default=60)
parser.add_argument("--gain-left", type=float, default=0.88)
parser.add_argument("--report", help="JSON-отчёт")
args = parser.parse_args(argv)
stages = [s.strip() for s in args.stages.split(",") if s.strip()]
out_path = args.out or os.path.splitext(args.midi)[0] + "_plus.mid"
report_path = args.report or os.path.splitext(out_path)[0] + "_report.json"
piece = load_midi(args.midi)
print("вход: %s | нот %d | %.1f с | %.0f BPM"
% (os.path.basename(args.midi), len(piece.notes), piece.duration, piece.bpm), flush=True)
options = {
"audio": args.audio, "tempo": args.tempo,
"strength": args.quantize_strength, "division": args.division,
"swing": args.swing, "auto_tempo": args.auto_tempo,
"split_pitch": args.split_pitch, "gain_left": args.gain_left,
}
report = run_pipeline(piece, stages, options)
save_midi(piece, out_path)
dump_json(report, report_path)
print("готово: %s | отчёт: %s" % (out_path, report_path), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())