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.", }