muscriptor-studio-suite / midi_component.pyi
fffiloni's picture
Upload 8 files
39c83e4 verified
Raw
History Blame Contribute Delete
10.2 kB
from __future__ import annotations
from bisect import bisect_right
from collections import defaultdict, deque
from pathlib import Path
from typing import Any
import gradio as gr
import mido
ROOT = Path(__file__).resolve().parent
FRONTEND = ROOT / "frontend"
MAX_FILE_BYTES = 5 * 1024 * 1024
MAX_NOTES = 50_000
TRACK_COLORS = [
"#8b5cf6",
"#22d3ee",
"#fb7185",
"#fbbf24",
"#34d399",
"#60a5fa",
"#f472b6",
"#a3e635",
"#fb923c",
"#c084fc",
]
PROGRAM_FAMILIES = [
"Piano",
"Percussions chromatiques",
"Orgue",
"Guitare",
"Basse",
"Cordes",
"Ensemble",
"Cuivres",
"Anches",
"Bois",
"Synthé lead",
"Synthé pad",
"Effets synthétiques",
"Instruments ethniques",
"Percussions",
"Effets sonores",
]
def _read_frontend(name: str) -> str:
return (FRONTEND / name).read_text(encoding="utf-8")
from gradio.events import Dependency
class MidiPlayer(gr.HTML):
"""A browser-synthesized MIDI player built with Gradio's custom HTML API."""
def __init__(self, value: Any | None = None, **kwargs: Any) -> None:
initial_value = value or {
"status": "empty",
"message": "Déposez un fichier MIDI pour commencer.",
}
super().__init__(
value=initial_value,
html_template=_read_frontend("player.html"),
css_template=_read_frontend("player.css"),
js_on_load=_read_frontend("player.js"),
apply_default_css=False,
min_height=680,
**kwargs,
)
def api_info(self) -> dict[str, Any]:
return {"type": "object"}
from typing import Callable, Literal, Sequence, Any, TYPE_CHECKING
from gradio.blocks import Block
if TYPE_CHECKING:
from gradio.components import Timer
from gradio.components.base import Component
class TempoMap:
def __init__(self, events: list[tuple[int, int]], ticks_per_beat: int) -> None:
if ticks_per_beat <= 0:
raise ValueError("Les fichiers MIDI avec division temporelle SMPTE ne sont pas pris en charge.")
collapsed: dict[int, int] = {0: 500_000}
for tick, tempo in sorted(events):
collapsed[tick] = tempo
self.ticks_per_beat = ticks_per_beat
self.segments: list[tuple[int, float, int]] = []
elapsed = 0.0
previous_tick = 0
previous_tempo = collapsed[0]
for tick, tempo in sorted(collapsed.items()):
if tick > 0:
elapsed += mido.tick2second(
tick - previous_tick,
ticks_per_beat,
previous_tempo,
)
self.segments.append((tick, elapsed, tempo))
previous_tick = tick
previous_tempo = tempo
self._ticks = [segment[0] for segment in self.segments]
def seconds(self, tick: int) -> float:
index = max(0, bisect_right(self._ticks, tick) - 1)
start_tick, start_seconds, tempo = self.segments[index]
return start_seconds + mido.tick2second(
tick - start_tick,
self.ticks_per_beat,
tempo,
)
def _display_name(track: mido.MidiTrack, index: int) -> str:
for message in track:
if message.type == "track_name" and message.name.strip():
return message.name.strip()[:80]
return f"Piste {index + 1}"
def _instrument_name(programs: set[int], channels: set[int]) -> str:
if 9 in channels:
return "Percussions"
if not programs:
return "Instrument MIDI"
names = list(dict.fromkeys(PROGRAM_FAMILIES[program // 8] for program in sorted(programs)))
return ", ".join(names[:2]) + ("…" if len(names) > 2 else "")
def parse_midi(path: str | Path, original_name: str | None = None) -> dict[str, Any]:
file_path = Path(path)
midi = mido.MidiFile(file_path, clip=True)
if midi.type == 2:
raise ValueError("Les fichiers MIDI type 2 (séquences asynchrones) ne sont pas pris en charge.")
tempo_events: list[tuple[int, int]] = []
time_signatures: list[dict[str, int]] = []
global_end_tick = 0
title = ""
for track in midi.tracks:
tick = 0
for message in track:
tick += message.time
if message.type == "set_tempo":
tempo_events.append((tick, message.tempo))
elif message.type == "time_signature":
time_signatures.append(
{
"tick": tick,
"numerator": message.numerator,
"denominator": message.denominator,
}
)
elif message.type == "track_name" and not title and message.name.strip():
title = message.name.strip()[:120]
global_end_tick = max(global_end_tick, tick)
tempo_map = TempoMap(tempo_events, midi.ticks_per_beat)
tracks: list[dict[str, Any]] = []
total_notes = 0
min_pitch = 127
max_pitch = 0
for source_index, track in enumerate(midi.tracks):
tick = 0
programs_by_channel: defaultdict[int, int] = defaultdict(int)
active: defaultdict[tuple[int, int], deque[tuple[int, int, int]]] = defaultdict(deque)
raw_notes: list[tuple[int, int, int, int, int, int]] = []
channels: set[int] = set()
programs: set[int] = set()
for message in track:
tick += message.time
if message.type == "program_change":
programs_by_channel[message.channel] = message.program
programs.add(message.program)
elif message.type == "note_on" and message.velocity > 0:
channel = message.channel
program = programs_by_channel[channel]
active[(channel, message.note)].append((tick, message.velocity, program))
channels.add(channel)
programs.add(program)
elif message.type in {"note_off", "note_on"}:
key = (message.channel, message.note)
if active[key]:
start_tick, velocity, program = active[key].popleft()
raw_notes.append(
(start_tick, max(tick, start_tick + 1), message.note, velocity, message.channel, program)
)
for (channel, pitch), pending in active.items():
while pending:
start_tick, velocity, program = pending.popleft()
raw_notes.append(
(start_tick, max(global_end_tick, start_tick + 1), pitch, velocity, channel, program)
)
if not raw_notes:
continue
raw_notes.sort(key=lambda note: (note[0], note[2]))
track_index = len(tracks)
notes = []
for start_tick, end_tick, pitch, velocity, channel, program in raw_notes:
start = tempo_map.seconds(start_tick)
end = tempo_map.seconds(end_tick)
notes.append(
{
"s": round(start, 6),
"e": round(max(end, start + 0.01), 6),
"p": pitch,
"v": velocity,
"c": channel,
"g": program,
"t": track_index,
}
)
min_pitch = min(min_pitch, pitch)
max_pitch = max(max_pitch, pitch)
total_notes += len(notes)
if total_notes > MAX_NOTES:
raise ValueError(f"Le fichier dépasse la limite de {MAX_NOTES:,} notes.")
tracks.append(
{
"index": track_index,
"source_index": source_index,
"name": _display_name(track, source_index),
"color": TRACK_COLORS[track_index % len(TRACK_COLORS)],
"instrument": _instrument_name(programs, channels),
"channels": sorted(channel + 1 for channel in channels),
"notes": notes,
}
)
if not tracks:
raise ValueError("Ce fichier MIDI ne contient aucune note lisible.")
duration = max(
tempo_map.seconds(global_end_tick),
max(note["e"] for track in tracks for note in track["notes"]),
)
initial_tempo = tempo_map.segments[0][2]
safe_name = Path(original_name or file_path.name).name[:160]
return {
"status": "ready",
"file_name": safe_name,
"title": title or Path(safe_name).stem,
"format": midi.type,
"ticks_per_beat": midi.ticks_per_beat,
"duration": round(duration, 6),
"bpm": round(mido.tempo2bpm(initial_tempo), 1),
"tempo_changes": len(tempo_map.segments),
"time_signature": (
f"{time_signatures[0]['numerator']}/{time_signatures[0]['denominator']}"
if time_signatures
else "—"
),
"track_count": len(tracks),
"note_count": total_notes,
"pitch_min": min_pitch,
"pitch_max": max_pitch,
"tracks": tracks,
}
def load_midi(value: Any) -> dict[str, Any]:
try:
if not isinstance(value, dict) or value.get("status") != "uploaded":
raise ValueError("Aucun fichier MIDI valide n’a été transmis.")
path = Path(str(value.get("path", "")))
original_name = Path(str(value.get("name", path.name))).name
if path.suffix.lower() not in {".mid", ".midi"} and Path(original_name).suffix.lower() not in {
".mid",
".midi",
}:
raise ValueError("Formats acceptés : .mid et .midi.")
if not path.is_file():
raise ValueError("Le fichier uploadé est introuvable.")
if path.stat().st_size > MAX_FILE_BYTES:
raise ValueError("Le fichier dépasse la limite de 5 Mo.")
return parse_midi(path, original_name)
except (EOFError, OSError, ValueError, mido.KeySignatureError) as exc:
return {
"status": "error",
"message": str(exc) or "Impossible de lire ce fichier MIDI.",
}