Victios7's picture
Upload 4 files
b848739 verified
Raw History Blame Contribute Delete
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()