Spaces:
Runtime error
Runtime error
Download app.py from Victios7/Aether_Studio_Aether_1.2_7B: direct link, hf CLI and curl.
- Browser
- Download file 29.5 kB
-
https://huggingface.co/spaces/Victios7/Aether_Studio_Aether_1.2_7B/resolve/main/app.py
- Command line
-
hf download hf://spaces/Victios7/Aether_Studio_Aether_1.2_7B/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Victios7/Aether_Studio_Aether_1.2_7B/resolve/main/app.py
29.5 kB
| import gradio as gr | |
| import tempfile | |
| import os | |
| import re | |
| import json | |
| import subprocess | |
| import urllib.parse | |
| import urllib.request | |
| import time | |
| import hashlib | |
| STUDIO_NAME = "Aether Studio" | |
| MODEL_DISPLAY_NAME = "Nemotron 3 Ultra" | |
| OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" | |
| OPENROUTER_KEY = os.environ.get( | |
| "OPENROUTER_API_KEY", | |
| "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", | |
| ) | |
| MODEL_ID = os.environ.get("OPENROUTER_MODEL", "nvidia/nemotron-3-ultra-550b-a55b:free") | |
| CACHE_DIR = "/tmp/aether_cache" | |
| os.makedirs(CACHE_DIR, exist_ok=True) | |
| SOUNDFONT_CANDIDATES = [ | |
| "/usr/share/sounds/sf2/FluidR3_GM.sf2", | |
| "/usr/share/sounds/sf2/default-GM.sf2", | |
| "/usr/share/sounds/sf2/TimGM6mb.sf2", | |
| "/usr/share/soundfonts/FluidR3_GM.sf2", | |
| "/usr/share/sounds/sf2/default.sf2", | |
| ] | |
| NOTE_TO_MIDI = { | |
| "C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3, "E": 4, "F": 5, | |
| "F#": 6, "Gb": 6, "G": 7, "G#": 8, "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11, | |
| } | |
| DUR = {"w": 4.0, "h": 2.0, "q": 1.0, "e": 0.5, "s": 0.25} | |
| GM_PROGRAM = { | |
| "piano": 0, "bright_piano": 1, "grand_piano": 0, "electric_piano": 4, "harpsichord": 6, | |
| "celesta": 8, "glockenspiel": 9, "music_box": 10, "vibraphone": 11, "marimba": 12, | |
| "xylophone": 13, "bells": 14, "dulcimer": 15, "organ": 19, "church_organ": 19, | |
| "accordion": 21, "harmonica": 22, "guitar": 24, "nylon_guitar": 24, "steel_guitar": 25, | |
| "clean_guitar": 27, "lute": 24, "oud": 24, "acoustic_bass": 32, "finger_bass": 33, | |
| "pick_bass": 34, "fretless_bass": 35, "synth_bass": 38, "violin": 40, "viola": 41, | |
| "cello": 42, "contrabass": 43, "pizzicato_strings": 45, "harp": 46, "timpani": 47, | |
| "strings": 48, "slow_strings": 49, "synth_strings": 50, "choir": 52, "synth_voice": 54, | |
| "orchestra_hit": 55, "trumpet": 56, "trombone": 57, "tuba": 58, "muted_trumpet": 59, | |
| "french_horn": 60, "brass": 61, "synth_brass": 62, "soprano_sax": 64, "alto_sax": 65, | |
| "tenor_sax": 66, "oboe": 68, "english_horn": 69, "bassoon": 70, "clarinet": 71, | |
| "piccolo": 72, "flute": 73, "recorder": 74, "pan_flute": 75, "whistle": 78, "ocarina": 79, | |
| "square_lead": 80, "saw_lead": 81, "calliope_lead": 82, "bass_lead": 87, "pad": 88, | |
| "warm_pad": 89, "polysynth_pad": 90, "choir_pad": 91, "bowed_pad": 92, "sitar": 104, | |
| "banjo": 105, "koto": 107, "kalimba": 108, "bagpipe": 109, "fiddle": 110, "steel_drums": 114, | |
| } | |
| SOLO_INSTRUMENTS = {"recorder", "flute", "whistle", "ocarina", "pan_flute"} | |
| SUSTAIN_INSTRUMENTS = { | |
| "piano", "grand_piano", "bright_piano", "electric_piano", "harp", | |
| "strings", "slow_strings", "synth_strings", "guitar", "lute", | |
| } | |
| CHORD_QUALITIES = { | |
| "": [0, 4, 7], "maj": [0, 4, 7], "m": [0, 3, 7], "min": [0, 3, 7], | |
| "7": [0, 4, 7, 10], "m7": [0, 3, 7, 10], "min7": [0, 3, 7, 10], | |
| "maj7": [0, 4, 7, 11], "dim": [0, 3, 6], "dim7": [0, 3, 6, 9], | |
| "aug": [0, 4, 8], "sus2": [0, 2, 7], "sus4": [0, 5, 7], "5": [0, 7], | |
| } | |
| _QUALITY_RE = r"(maj7|min7|sus2|sus4|dim7|maj|min|m7|dim|aug|7|5|m)?" | |
| DYNAMIC_LEVELS = {"pp": 42, "p": 58, "mp": 74, "mf": 90, "f": 106, "ff": 120} | |
| PERCUSSION_STYLES = {"epica", "marcha", "ninguno"} | |
| NOTE_TOKEN_RE = re.compile(r"^([A-G][#b]?)(\d)([qehws]?)([-!]{0,2})$") | |
| REST_TOKEN_RE = re.compile(r"^R([qehws])$", re.I) | |
| CHORD_TOKEN_RE = re.compile(r"^([A-G][#b]?)" + _QUALITY_RE + r"(\d(?:\.\d)?)?$") | |
| SYSTEM = """Eres el compositor de Aether Studio. Tu trabajo es generar partituras instrumentales en formato PMNS. | |
| RECHAZO: si el usuario NO pide musica/cancion/composicion, responde exactamente: | |
| lo siento no puedo hacer eso | |
| HERRAMIENTAS (usalas cuando las necesites, antes de la partitura final): | |
| 1) TOOL_SEARCH: <consulta> | |
| Busca en la web datos musicales (modos, tempos tipicos, instrumentacion de un genero, estructura de un estilo). | |
| 2) TOOL_VALIDATE: <partitura PMNS completa> | |
| Valida gramatica PMNS. Te devuelve OK o lista de errores con linea. | |
| 3) TOOL_INSTRUMENTS: | |
| Lista instrumentos GM validos. | |
| Flujo recomendado: | |
| - Si dudas del estilo/modo/instrumentacion -> TOOL_SEARCH | |
| - Escribe borrador PMNS | |
| - TOOL_VALIDATE si quieres comprobar | |
| - Cuando este listo, responde SOLO la partitura PMNS (sin TOOL_*, sin explicaciones) | |
| GRAMATICA PMNS: | |
| obra := cabecera seccion+ | |
| cabecera := "# " titulo / "## Estilo" / "## Tempo" / "## Tonalidad" | |
| seccion := "### " nombre + campos | |
| campos posibles: | |
| instrumento: <nombre GM> | |
| tempo: <BPM seccion> | |
| dinamica: pp|p|mp|mf|f|ff | |
| repetir: <1-8> | |
| percusion: epica|marcha|ninguno | |
| notas: <tokens> | |
| acordes: <tokens> | |
| Notas: Nota+Octava+Duracion[+articulacion] | |
| Nota: C C# Db D D# Eb E F F# Gb G G# Ab A A# Bb B | |
| Octava: 1-7 | |
| Duracion: w h q e s (si omites duracion = q) | |
| Articulacion: - legato ! acento | |
| Silencio: Rq Re Rh Rw Rs | |
| Acordes: Root+calidad+pulsos ej: Dm2 Fmaj74 Am (pulsos opcionales, default 2) | |
| calidades: m min maj 7 m7 maj7 dim dim7 aug sus2 sus4 5 | |
| Reglas de calidad: | |
| - 6-8 secciones para ~1 minuto | |
| - >=10 notas por seccion de melodia | |
| - Usa tu conocimiento musical + resultados de tools | |
| - No inventes instrumentos fuera del catalogo | |
| - No escribas letra cantada, solo instrumental | |
| - No uses escalas mecanicas como unica melodia | |
| Ejemplo de forma (no copies el estilo, solo la forma): | |
| # Titulo | |
| ## Estilo | |
| descripcion | |
| ## Tempo | |
| 100 | |
| ## Tonalidad | |
| D dorico | |
| ## Estructura | |
| ### Intro | |
| instrumento: lute | |
| dinamica: p | |
| notas: D4q F4q A4q D5h | |
| acordes: Dm2 Am2 | |
| ### Tema | |
| instrumento: recorder | |
| dinamica: mf | |
| repetir: 1 | |
| notas: A4q C5q D5q F5h E5q D5q C5h | |
| acordes: Dm2 F2 | |
| ### Outro | |
| instrumento: lute | |
| dinamica: p | |
| notas: D4h A3h D3w | |
| acordes: Dm4 | |
| """ | |
| def parse_note_token(tok): | |
| m = NOTE_TOKEN_RE.match(tok.strip()) | |
| if not m: | |
| return None | |
| name, octv, d, art = m.groups() | |
| if name not in NOTE_TO_MIDI: | |
| return None | |
| midi = NOTE_TO_MIDI[name] + (int(octv) + 1) * 12 | |
| beats = DUR.get(d or "q", 1.0) | |
| return midi, beats, ("-" in art), ("!" in art) | |
| def parse_chord_token(tok): | |
| m = CHORD_TOKEN_RE.match(tok.strip()) | |
| if not m: | |
| return None | |
| root, quality, beats = m.groups() | |
| if root not in NOTE_TO_MIDI: | |
| return None | |
| return { | |
| "root": NOTE_TO_MIDI[root] + 48, | |
| "intervals": CHORD_QUALITIES[quality or ""], | |
| "beats": float(beats) if beats else 2.0, | |
| } | |
| def detect_inst(text): | |
| t = text.lower().strip() | |
| for w in ("flauta dulce", "flauta de pico", "recorder"): | |
| if w in t: | |
| return "recorder" | |
| if "flauta" in t or "flute" in t: | |
| return "flute" | |
| for k in sorted(GM_PROGRAM.keys(), key=len, reverse=True): | |
| if k in t: | |
| return k | |
| for es, en in ( | |
| ("laud", "lute"), ("laúd", "lute"), ("guitarra", "guitar"), | |
| ("arpa", "harp"), ("trompeta", "trumpet"), ("cuerdas", "strings"), | |
| ("organo", "organ"), ("campanas", "bells"), | |
| ): | |
| if es in t: | |
| return en | |
| return "piano" | |
| def parse_pmns(md): | |
| errors = [] | |
| work = {"title": "Sin titulo", "tempo": 100, "style": "", "key": "", "sections": []} | |
| tm = re.search(r"##\s*Tempo\s*\n\s*(\d+)", md, re.I) | |
| if tm: | |
| work["tempo"] = max(40, min(220, int(tm.group(1)))) | |
| ttm = re.search(r"^\s*#\s+(.+)$", md, re.M) | |
| if ttm: | |
| work["title"] = ttm.group(1).strip() | |
| sm = re.search(r"##\s*Estilo\s*\n(.+?)(?=\n##|\n###|$)", md, re.I | re.S) | |
| if sm: | |
| work["style"] = sm.group(1).strip() | |
| km = re.search(r"##\s*Tonalidad\s*\n(.+?)(?=\n##|\n###|$)", md, re.I | re.S) | |
| if km: | |
| work["key"] = km.group(1).strip() | |
| default_inst = detect_inst(work["style"]) | |
| for raw in re.split(r"###\s+", md)[1:]: | |
| lines = raw.split("\n") | |
| name = lines[0].strip() | |
| line0 = md[: md.find(raw)].count("\n") + 1 if raw in md else 0 | |
| sec = { | |
| "name": name, "instrument": default_inst, "tempo": work["tempo"], | |
| "notes": "", "chords": "", "dynamic": None, "repeat": 1, | |
| "bass": "auto", "percusion": "ninguno", "line_no": line0, | |
| } | |
| for i, line in enumerate(lines[1:], start=line0 + 1): | |
| low = line.lower().strip() | |
| if low.startswith("notas:"): | |
| sec["notes"] = line.split(":", 1)[1].strip() | |
| elif low.startswith("acordes:"): | |
| sec["chords"] = line.split(":", 1)[1].strip() | |
| elif low.startswith("instrumento:"): | |
| raw_inst = line.split(":", 1)[1].strip() | |
| inst = detect_inst(raw_inst) | |
| if raw_inst.lower().split()[0] not in GM_PROGRAM and inst == "piano" and "piano" not in raw_inst.lower(): | |
| errors.append(f"linea ~{i}: instrumento desconocido '{raw_inst}'") | |
| sec["instrument"] = inst | |
| elif low.startswith("tempo:"): | |
| m = re.search(r"(\d+)", line) | |
| sec["tempo"] = max(40, min(220, int(m.group(1)))) if m else work["tempo"] | |
| elif low.startswith("dinamica:") or low.startswith("dinámica:"): | |
| dyn = line.split(":", 1)[1].strip().lower() | |
| if dyn in DYNAMIC_LEVELS: | |
| sec["dynamic"] = dyn | |
| else: | |
| errors.append(f"linea ~{i}: dinamica invalida '{dyn}'") | |
| elif low.startswith("percusion:") or low.startswith("percusión:"): | |
| p = line.split(":", 1)[1].strip().lower() | |
| sec["percusion"] = p if p in PERCUSSION_STYLES else "ninguno" | |
| elif low.startswith("repetir:"): | |
| m = re.search(r"(\d+)", line) | |
| sec["repeat"] = max(1, min(8, int(m.group(1)))) if m else 1 | |
| if not sec["notes"]: | |
| errors.append(f"seccion '{name}': falta notas") | |
| else: | |
| bad = [] | |
| for tok in sec["notes"].split(): | |
| if REST_TOKEN_RE.match(tok): | |
| continue | |
| if not parse_note_token(tok): | |
| bad.append(tok) | |
| if bad and len(errors) < 8: | |
| errors.append(f"seccion '{name}': tokens invalidos {bad[:5]}") | |
| for tok in sec["chords"].split(): | |
| if not parse_chord_token(tok) and len(errors) < 10: | |
| errors.append(f"seccion '{name}': acorde invalido '{tok}'") | |
| work["sections"].append(sec) | |
| if not work["sections"]: | |
| errors.append("no hay secciones ###") | |
| return work, errors | |
| def score_to_midi(md, path, strict=True): | |
| import pretty_midi | |
| work, errors = parse_pmns(md) | |
| if errors and strict: | |
| raise ValueError("; ".join(errors[:6])) | |
| pm = pretty_midi.PrettyMIDI(initial_tempo=work["tempo"]) | |
| instruments = {} | |
| def get_inst(name, is_drum=False): | |
| key = "drums" if is_drum else name | |
| if key not in instruments: | |
| prog = 0 if is_drum else GM_PROGRAM.get(name, 0) | |
| instruments[key] = pretty_midi.Instrument(program=prog, is_drum=is_drum, name=name) | |
| return instruments[key] | |
| global_time = 0.0 | |
| n_sections = len(work["sections"]) | |
| for s_idx, sec in enumerate(work["sections"]): | |
| beat_sec = 60.0 / sec["tempo"] | |
| base_vel = DYNAMIC_LEVELS[sec["dynamic"]] if sec["dynamic"] else int(58 + (s_idx / max(n_sections - 1, 1)) * 54) | |
| inst_name = sec["instrument"] | |
| is_solo = inst_name in SOLO_INSTRUMENTS | |
| for _rep in range(sec["repeat"]): | |
| sec_start = global_time | |
| inst = get_inst(inst_name) | |
| t = sec_start | |
| for tok in sec["notes"].split(): | |
| rm = REST_TOKEN_RE.match(tok) | |
| if rm: | |
| t += DUR[rm.group(1).lower()] * beat_sec | |
| continue | |
| parsed = parse_note_token(tok) | |
| if not parsed: | |
| continue | |
| midi, beats, legato, accent = parsed | |
| vel = min(127, base_vel + (14 if accent else 0)) | |
| gate = 1.0 if legato else 0.96 | |
| dur = beats * beat_sec | |
| inst.notes.append( | |
| pretty_midi.Note( | |
| velocity=vel, | |
| pitch=min(max(midi, 21), 108), | |
| start=t, | |
| end=t + max(dur * gate, 0.05), | |
| ) | |
| ) | |
| t += dur | |
| melodic_end = t | |
| if sec["chords"] and not is_solo: | |
| ch_name = ( | |
| "warm_pad" | |
| if inst_name in ("pad", "strings", "choir", "synth_strings", "choir_pad", "brass") | |
| else "harp" | |
| ) | |
| ch_inst = get_inst(ch_name) | |
| ct = sec_start | |
| for tok in sec["chords"].split(): | |
| ch = parse_chord_token(tok) | |
| if not ch: | |
| continue | |
| dur_b = ch["beats"] | |
| for j, off in enumerate(ch["intervals"]): | |
| ch_inst.notes.append( | |
| pretty_midi.Note( | |
| velocity=min(127, int(base_vel * 0.34) + (5 if j == 0 else 0)), | |
| pitch=min(max(ch["root"] + off, 21), 108), | |
| start=ct, | |
| end=ct + dur_b * beat_sec, | |
| ) | |
| ) | |
| ct += dur_b * beat_sec | |
| if sec["bass"] in ("auto", "si", "sí") and sec["chords"] and not is_solo: | |
| bass = get_inst("finger_bass") | |
| bt = sec_start | |
| for tok in sec["chords"].split(): | |
| ch = parse_chord_token(tok) | |
| if not ch: | |
| continue | |
| root_bass = ch["root"] - 24 | |
| for k in range(max(1, int(ch["beats"]))): | |
| bass.notes.append( | |
| pretty_midi.Note( | |
| velocity=int(base_vel * 0.72), | |
| pitch=min(max(root_bass, 21), 48), | |
| start=bt + k * beat_sec, | |
| end=bt + k * beat_sec + 0.9 * beat_sec, | |
| ) | |
| ) | |
| bt += ch["beats"] * beat_sec | |
| if sec["percusion"] in ("epica", "marcha"): | |
| drums = get_inst("timpani") | |
| pt = sec_start | |
| while pt < melodic_end - 0.01: | |
| drums.notes.append( | |
| pretty_midi.Note( | |
| velocity=int(base_vel * 0.85), | |
| pitch=41 if sec["percusion"] == "epica" else 45, | |
| start=pt, | |
| end=pt + 0.2, | |
| ) | |
| ) | |
| pt += (2.0 if sec["percusion"] == "epica" else 1.0) * beat_sec | |
| if inst_name in SUSTAIN_INSTRUMENTS and melodic_end > sec_start: | |
| inst.control_changes.append(pretty_midi.ControlChange(64, 127, sec_start)) | |
| inst.control_changes.append(pretty_midi.ControlChange(64, 0, melodic_end)) | |
| global_time = melodic_end | |
| for inst in instruments.values(): | |
| if inst.notes: | |
| pm.instruments.append(inst) | |
| if not pm.instruments: | |
| if strict: | |
| raise ValueError("No hay notas interpretables") | |
| inst = get_inst("piano") | |
| for i, p in enumerate([60, 64, 67, 72]): | |
| inst.notes.append(pretty_midi.Note(90, p, i * 0.5, i * 0.5 + 0.4)) | |
| pm.instruments.append(inst) | |
| pm.write(path) | |
| return path, work | |
| def find_soundfont(): | |
| for p in SOUNDFONT_CANDIDATES: | |
| if os.path.isfile(p): | |
| return p | |
| return None | |
| def midi_to_mp3(midi_path, mp3_path): | |
| from pydub import AudioSegment | |
| sf2 = find_soundfont() | |
| if not sf2: | |
| raise RuntimeError("No SoundFont (instala fluid-soundfont-gm)") | |
| wav_path = midi_path.replace(".mid", ".wav") | |
| subprocess.run( | |
| ["fluidsynth", "-ni", "-g", "1.3", "-R", "1", "-C", "1", sf2, midi_path, "-F", wav_path, "-r", "44100"], | |
| check=True, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| audio = AudioSegment.from_wav(wav_path) | |
| peak = audio.max_dBFS | |
| if peak < -3: | |
| audio = audio.apply_gain(-3 - peak) | |
| audio.export(mp3_path, format="mp3", bitrate="192k") | |
| if os.path.exists(wav_path): | |
| os.unlink(wav_path) | |
| return mp3_path | |
| def tool_web_search(query, max_results=5): | |
| results = [] | |
| try: | |
| q = urllib.parse.quote(query) | |
| url = f"https://api.duckduckgo.com/?q={q}&format=json&no_html=1&skip_disambig=1" | |
| req = urllib.request.Request(url, headers={"User-Agent": "AetherStudio/1.0"}) | |
| with urllib.request.urlopen(req, timeout=8) as resp: | |
| data = json.loads(resp.read().decode("utf-8", errors="ignore")) | |
| if data.get("AbstractText"): | |
| results.append(data["AbstractText"][:500]) | |
| for t in data.get("RelatedTopics", [])[:max_results]: | |
| if isinstance(t, dict) and t.get("Text"): | |
| results.append(t["Text"][:300]) | |
| except Exception: | |
| pass | |
| try: | |
| q = urllib.parse.quote(query.replace(" ", "_")) | |
| url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{q}" | |
| req = urllib.request.Request(url, headers={"User-Agent": "AetherStudio/1.0"}) | |
| with urllib.request.urlopen(req, timeout=8) as resp: | |
| data = json.loads(resp.read().decode("utf-8", errors="ignore")) | |
| if data.get("extract"): | |
| results.append(data["extract"][:700]) | |
| except Exception: | |
| pass | |
| return "\n".join(f"- {r}" for r in results[:6]) if results else "Sin resultados. Usa tu conocimiento." | |
| def tool_validate(md): | |
| _, errors = parse_pmns(md) | |
| if not errors: | |
| return "OK: partitura valida" | |
| return "ERRORES:\n" + "\n".join(f"- {e}" for e in errors[:12]) | |
| def tool_instruments(): | |
| return "Instrumentos validos: " + ", ".join(sorted(GM_PROGRAM.keys())) | |
| def openrouter_chat(messages, max_tokens=2000): | |
| body = json.dumps({ | |
| "model": MODEL_ID, | |
| "messages": messages, | |
| "max_tokens": max_tokens, | |
| "temperature": 0.7, | |
| }).encode() | |
| req = urllib.request.Request( | |
| OPENROUTER_URL, | |
| data=body, | |
| headers={ | |
| "Authorization": f"Bearer {OPENROUTER_KEY}", | |
| "Content-Type": "application/json", | |
| "HTTP-Referer": "https://huggingface.co", | |
| "X-Title": "Aether Studio", | |
| }, | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(req, timeout=120) as resp: | |
| data = json.loads(resp.read().decode()) | |
| return data["choices"][0]["message"]["content"] | |
| def clean_score(text): | |
| text = text.strip() | |
| if text.lower().startswith("lo siento no puedo hacer eso") and "notas:" not in text.lower(): | |
| return "lo siento no puedo hacer eso" | |
| idx = text.find("#") | |
| if idx == -1: | |
| return None | |
| md = text[idx:] | |
| for stop in ("```", "<|im_end|>"): | |
| if stop in md: | |
| md = md[: md.find(stop)] | |
| if "notas:" not in md.lower(): | |
| return None | |
| return md.strip() | |
| def agent_compose(prompt, max_rounds=4): | |
| messages = [ | |
| {"role": "system", "content": SYSTEM}, | |
| {"role": "user", "content": prompt.strip()}, | |
| ] | |
| last = "" | |
| for _ in range(max_rounds): | |
| last = openrouter_chat(messages, max_tokens=2200) | |
| search = re.search(r"TOOL_SEARCH:\s*(.+)", last, re.I) | |
| if search: | |
| q = search.group(1).strip().split("\n")[0][:200] | |
| result = tool_web_search(q) | |
| messages.append({"role": "assistant", "content": last}) | |
| messages.append({"role": "user", "content": f"Resultado TOOL_SEARCH ({q}):\n{result}\nContinua. Si ya puedes, escribe SOLO la partitura PMNS."}) | |
| continue | |
| if re.search(r"TOOL_INSTRUMENTS\s*$", last, re.I | re.M) or "TOOL_INSTRUMENTS" in last and "notas:" not in last.lower(): | |
| result = tool_instruments() | |
| messages.append({"role": "assistant", "content": last}) | |
| messages.append({"role": "user", "content": f"{result}\nContinua con la partitura PMNS."}) | |
| continue | |
| val = re.search(r"TOOL_VALIDATE:\s*(.+)", last, re.I | re.S) | |
| if val and "notas:" in last.lower(): | |
| md_try = clean_score(last) or last | |
| result = tool_validate(md_try) | |
| messages.append({"role": "assistant", "content": last}) | |
| messages.append({"role": "user", "content": f"Resultado TOOL_VALIDATE:\n{result}\nSi hay errores, corrige y escribe SOLO la partitura final. Si OK, escribe SOLO la partitura."}) | |
| continue | |
| md = clean_score(last) | |
| if md and md != "lo siento no puedo hacer eso": | |
| return md, last | |
| if md == "lo siento no puedo hacer eso": | |
| return md, last | |
| messages.append({"role": "assistant", "content": last}) | |
| messages.append({"role": "user", "content": "Responde ahora SOLO con la partitura PMNS completa (empieza con #)."}) | |
| md = clean_score(last) | |
| return md or last, last | |
| LIBRARY = [] | |
| def get_duration(mp3_path): | |
| try: | |
| from pydub import AudioSegment | |
| a = AudioSegment.from_mp3(mp3_path) | |
| s = len(a) / 1000 | |
| return f"{int(s // 60)}:{int(s % 60):02d}" | |
| except Exception: | |
| return "--:--" | |
| def render_library(): | |
| if not LIBRARY: | |
| return '<div class="empty-library"><div class="empty-icon">🎵</div><div class="empty-title">Aún no hay composiciones</div><div class="empty-sub">Escribe una descripción y pulsa Crear.</div></div>' | |
| cards = [] | |
| for s in LIBRARY: | |
| h = abs(hash(s["title"])) % 360 | |
| grad = f"linear-gradient(135deg, hsl({h},65%,45%) 0%, hsl({(h+50)%360},65%,25%) 100%)" | |
| style_short = s["style"][:70] + ("…" if len(s["style"]) > 70 else "") | |
| cards.append( | |
| f'<div class="song-card" onclick="selectSong(\'{s["id"]}\')">' | |
| f'<div class="song-cover" style="background:{grad}"><span class="cover-icon">♪</span>' | |
| f'<span class="cover-dur">{s["duration"]}</span></div>' | |
| f'<div class="song-info"><div class="song-title">{s["title"]}</div>' | |
| f'<div class="song-meta">{style_short}</div>' | |
| f'<div class="song-stats"><span>{s["tempo"]} BPM</span><span>·</span><span>{s["key"]}</span></div></div>' | |
| f'<div class="song-play">▶</div></div>' | |
| ) | |
| return f'<div class="library">{"".join(cards)}</div>' | |
| CUSTOM_CSS = """ | |
| .gradio-container { background: #0a0a0d !important; color: #e5e7eb !important; font-family: Inter, sans-serif !important; max-width: 100% !important; } | |
| footer { display: none !important; } | |
| #aether-topbar { display:flex; align-items:center; gap:14px; padding:14px 26px; background:#0f0f14; border-bottom:1px solid #1c1c25; } | |
| #aether-logo { width:34px; height:34px; border-radius:9px; background:linear-gradient(135deg,#8b5cf6,#06b6d4); display:flex; align-items:center; justify-content:center; } | |
| #aether-brand-name { font-size:15px; font-weight:700; color:#fff; } | |
| #aether-brand-model { font-size:11px; font-weight:600; color:#67e8f9; background:rgba(6,182,212,0.10); border:1px solid rgba(6,182,212,0.28); padding:3px 9px; border-radius:999px; margin-left:10px; } | |
| #aether-main { display:flex; gap:18px; padding:18px; min-height:70vh; } | |
| #aether-create { width:380px; flex-shrink:0; background:#0f0f14; border:1px solid #1c1c25; border-radius:14px; padding:18px; } | |
| #compose-btn { background:linear-gradient(135deg,#8b5cf6,#06b6d4) !important; border:none !important; border-radius:11px !important; color:#fff !important; font-weight:700 !important; width:100% !important; } | |
| #aether-library { flex:1; background:#0f0f14; border:1px solid #1c1c25; border-radius:14px; padding:18px; } | |
| .library { display:flex; flex-direction:column; gap:10px; } | |
| .song-card { display:flex; align-items:center; gap:14px; padding:12px; background:#13131a; border:1px solid #1c1c25; border-radius:12px; cursor:pointer; } | |
| .song-cover { width:64px; height:64px; border-radius:9px; display:flex; align-items:center; justify-content:center; position:relative; } | |
| .cover-dur { position:absolute; bottom:5px; right:6px; font-size:10px; color:#fff; background:rgba(0,0,0,0.65); padding:1px 5px; border-radius:4px; } | |
| .song-title { font-size:14.5px; font-weight:600; color:#fff; } | |
| .song-meta { font-size:12px; color:#94a3b8; } | |
| .song-stats { font-size:11px; color:#64748b; display:flex; gap:6px; } | |
| .song-play { width:34px; height:34px; border-radius:50%; background:rgba(139,92,246,0.15); color:#c4b5fd; display:flex; align-items:center; justify-content:center; } | |
| .empty-library { text-align:center; padding:60px 20px; color:#64748b; } | |
| #aether-player { padding:12px 24px; background:#0c0c11; border-top:1px solid #1c1c25; } | |
| #song-selector { display:none !important; } | |
| """ | |
| HEADER_HTML = f"""<div id="aether-topbar"><div id="aether-logo">🎼</div><div id="aether-brand-name">{STUDIO_NAME}</div><span id="aether-brand-model">{MODEL_DISPLAY_NAME}</span></div>""" | |
| SELECT_JS = """<script>function selectSong(id){const el=document.querySelector('#song-selector input, #song-selector textarea');if(el){el.value=id;el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));}}</script>""" | |
| def process_request(prompt): | |
| if not prompt or not prompt.strip(): | |
| yield "Escribe qué canción quieres.", "", render_library(), gr.update(), gr.update() | |
| return | |
| yield "🎼 *Componiendo con Nemotron 3 Ultra (OpenRouter)...*", "", render_library(), gr.update(), gr.update() | |
| try: | |
| md, raw = agent_compose(prompt) | |
| except Exception as e: | |
| yield f"❌ Error OpenRouter: {e}", str(e), render_library(), gr.update(), gr.update() | |
| return | |
| if md == "lo siento no puedo hacer eso": | |
| yield "🚫 No es una petición musical.", raw, render_library(), gr.update(), gr.update() | |
| return | |
| if not md or "notas:" not in md.lower(): | |
| yield "🚫 El modelo no devolvió partitura válida.", raw, render_library(), gr.update(), gr.update() | |
| return | |
| yield "⏳ *Sintetizando audio...*", raw, render_library(), gr.update(), gr.update() | |
| mid = tempfile.NamedTemporaryFile(suffix=".mid", delete=False).name | |
| mp3 = mid.replace(".mid", ".mp3") | |
| try: | |
| try: | |
| _, work = score_to_midi(md, mid, strict=True) | |
| warnings = [] | |
| except ValueError as ve: | |
| _, work = score_to_midi(md, mid, strict=False) | |
| warnings = [str(ve)] | |
| midi_to_mp3(mid, mp3) | |
| song_id = hashlib.md5(f"{work['title']}{time.time()}".encode()).hexdigest()[:10] | |
| cached_mp3 = os.path.join(CACHE_DIR, f"{song_id}.mp3") | |
| subprocess.run(["cp", mp3, cached_mp3], check=False) | |
| song_meta = { | |
| "id": song_id, | |
| "title": work.get("title", "Pieza"), | |
| "style": work.get("style") or "Instrumental", | |
| "tempo": work.get("tempo", 100), | |
| "key": work.get("key") or "—", | |
| "duration": get_duration(cached_mp3), | |
| "path": cached_mp3, | |
| } | |
| LIBRARY.insert(0, song_meta) | |
| status = f"✅ **{song_meta['title']}** lista · {song_meta['duration']} · {song_meta['tempo']} BPM" | |
| if warnings: | |
| status += f"\n\n*(validación parcial: {warnings[0][:120]})*" | |
| yield status, md, render_library(), cached_mp3, gr.update(value=song_id) | |
| except Exception as e: | |
| yield f"❌ Error sintetizando: {e}", md, render_library(), gr.update(), gr.update() | |
| finally: | |
| if os.path.exists(mid): | |
| os.unlink(mid) | |
| if os.path.exists(mp3): | |
| try: | |
| os.unlink(mp3) | |
| except Exception: | |
| pass | |
| def load_song_from_library(song_id): | |
| if not song_id: | |
| return None | |
| for s in LIBRARY: | |
| if s["id"] == song_id: | |
| return s["path"] | |
| return None | |
| with gr.Blocks(css=CUSTOM_CSS, title=STUDIO_NAME) as demo: | |
| gr.HTML(HEADER_HTML + SELECT_JS) | |
| song_selector = gr.Textbox(elem_id="song-selector", visible=False) | |
| with gr.Row(elem_id="aether-main"): | |
| with gr.Column(elem_id="aether-create"): | |
| gr.HTML('<div class="panel-title">Describe tu pieza</div>') | |
| prompt_in = gr.Textbox(placeholder="Ej: canción medieval épica con laúd y recorder...", show_label=False, lines=5) | |
| compose_btn = gr.Button("✦ Crear composición", elem_id="compose-btn") | |
| gr.HTML('<div class="panel-title" style="margin-top:16px">Estado</div>') | |
| status_out = gr.Markdown("*Listo.*") | |
| with gr.Accordion("Partitura / debug", open=False): | |
| raw_out = gr.Textbox(show_label=False, lines=12, interactive=False) | |
| with gr.Column(elem_id="aether-library"): | |
| gr.HTML('<div class="panel-title">Biblioteca</div>') | |
| library_out = gr.HTML(render_library()) | |
| with gr.Row(elem_id="aether-player"): | |
| audio_out = gr.Audio(show_label=False, type="filepath") | |
| compose_btn.click(fn=process_request, inputs=[prompt_in], outputs=[status_out, raw_out, library_out, audio_out, song_selector]) | |
| prompt_in.submit(fn=process_request, inputs=[prompt_in], outputs=[status_out, raw_out, library_out, audio_out, song_selector]) | |
| song_selector.change(fn=load_song_from_library, inputs=[song_selector], outputs=[audio_out]) | |
| if __name__ == "__main__": | |
| demo.queue(max_size=4).launch() | |