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