Spaces:
Running on Zero
Running on Zero
| 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.", | |
| } |