SF2 / app.py
PlayerBPlaytime's picture
Update app.py
0768555 verified
Raw
History Blame Contribute Delete
27.2 kB
import gradio as gr
import struct
import os
import tempfile
import io
import re
import zipfile
import numpy as np
import soundfile as sf
import traceback
###############################################################################
# MAPEO DE NOTAS
###############################################################################
NOTE_NAMES = {
'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11
}
MIDI_TO_NAME = {}
for octave in range(-1, 10):
for name, semitone in NOTE_NAMES.items():
midi = (octave + 1) * 12 + semitone
if 0 <= midi <= 127:
MIDI_TO_NAME[midi] = f"{name}{octave}"
midi_sharp = midi + 1
if 0 <= midi_sharp <= 127:
sharp_names = {0: 'C#', 2: 'D#', 5: 'F#', 7: 'G#', 9: 'A#'}
if semitone in sharp_names:
MIDI_TO_NAME[midi_sharp] = f"{sharp_names[semitone]}{octave}"
def _parse_single_note(note_str):
"""
Convierte un string de nota (ej: 'E4', 'F#3', 'Bb2') a número MIDI.
"""
pattern = r'^([A-Ga-g])(#|b|♯|♭)?(\d+)$'
m = re.match(pattern, note_str.strip())
if not m:
return None
note_letter = m.group(1).upper()
accidental = m.group(2) or ''
octave = int(m.group(3))
if note_letter not in NOTE_NAMES:
return None
if octave > 9:
return None
midi = (octave + 1) * 12 + NOTE_NAMES[note_letter]
if accidental in ('#', '♯'):
midi += 1
elif accidental in ('b', '♭'):
midi -= 1
if 0 <= midi <= 127:
return midi
return None
def parse_note_name(filename):
"""
Extrae la nota MIDI del nombre del archivo.
Soporta formatos:
C3.wav -> 48
F#4.wav -> 66
Ab3.wav -> 56 (bemol)
Piano_C3.wav -> 48
C3_piano.wav -> 48
060.wav -> 60 (número MIDI directo)
Nylon+Steel_E4_127.wav -> 64 (Nombre_Nota_Velocidad)
Guitar_F#3_64.wav -> 54
Bass_Bb2_100.wav -> 46
"""
name = os.path.splitext(filename)[0]
# ── Formato Nombre_Nota_Velocidad ─────────────────────────────────────
velocity_pattern = r'[_\-]([A-Ga-g](?:#|b|♯|♭)?\d+)(?:[_\-]\d+)?$'
vel_match = re.search(velocity_pattern, name)
if vel_match:
note_str = vel_match.group(1)
midi = _parse_single_note(note_str)
if midi is not None:
return midi
# ── Nota musical con octava en cualquier parte ────────────────────────
pattern = r'([A-Ga-g])(#|b|♯|♭)?(\d+)'
matches = list(re.finditer(pattern, name))
if matches:
valid_matches = []
for m in matches:
note_letter = m.group(1).upper()
octave = int(m.group(3))
if octave > 9:
continue
start = m.start()
if start > 0 and name[start - 1].isdigit():
continue
valid_matches.append(m)
if valid_matches:
preferred = None
for m in valid_matches:
start = m.start()
if start == 0 or name[start - 1] in ('_', '-', ' '):
preferred = m
break
best = preferred if preferred else valid_matches[0]
note_letter = best.group(1).upper()
accidental = best.group(2) or ''
octave = int(best.group(3))
if note_letter in NOTE_NAMES:
midi = (octave + 1) * 12 + NOTE_NAMES[note_letter]
if accidental in ('#', '♯'):
midi += 1
elif accidental in ('b', '♭'):
midi -= 1
if 0 <= midi <= 127:
return midi
# ── Número MIDI directo (060, 48, etc.) ───────────────────────────────
num_match = re.search(r'(?<!\d)(\d{2,3})(?!\d)', name)
if num_match:
num = int(num_match.group(1))
if 0 <= num <= 127:
return num
return None
def midi_to_note_name(midi):
"""Convierte número MIDI a nombre de nota"""
if midi in MIDI_TO_NAME:
return MIDI_TO_NAME[midi]
note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
octave = (midi // 12) - 1
note = note_names[midi % 12]
return f"{note}{octave}"
###############################################################################
# LECTURA DE AUDIO — SIN COMPRESIÓN, PRESERVA STEREO
###############################################################################
def read_audio_file(audio_bytes):
"""
Lee un archivo de audio y retorna (float32_data, sample_rate, num_channels).
Preserva stereo si el archivo es stereo.
"""
try:
data, sr = sf.read(io.BytesIO(audio_bytes), dtype='float32', always_2d=True)
num_channels = data.shape[1]
return data, sr, num_channels
except Exception:
return None, None, None
def float_to_pcm16(audio):
"""float32 -> int16 bytes (funciona con mono y stereo interleaved)"""
clipped = np.clip(audio, -1.0, 1.0)
return (clipped * 32767).astype(np.int16).tobytes()
def normalize_audio(audio):
"""Normaliza al pico dado (funciona con mono y stereo)"""
peak = 0.95
max_val = np.max(np.abs(audio))
if max_val > 0.0001:
return audio * (peak / max_val)
return audio
def trim_silence(audio, sr, num_channels, threshold_db=-50):
"""
Recorta silencio al inicio y final.
Funciona con audio 2D (samples, channels).
"""
threshold = 10 ** (threshold_db / 20.0)
# Usar el máximo absoluto de todos los canales para detectar silencio
abs_audio = np.max(np.abs(audio), axis=1)
margin_samples = int(sr * 0.005)
start = 0
for i in range(len(abs_audio)):
if abs_audio[i] > threshold:
start = max(0, i - margin_samples)
break
end = len(abs_audio)
for i in range(len(abs_audio) - 1, -1, -1):
if abs_audio[i] > threshold:
end = min(len(abs_audio), i + int(sr * 0.1))
break
if start >= end:
return audio
return audio[start:end]
###############################################################################
# SF2 WRITER — SOPORTE STEREO
###############################################################################
class SF2Writer:
def __init__(self, name="SoundFont"):
self.name = name
self.samples = []
self.instruments = []
self.presets = []
def add_sample_mono(self, name, pcm_16bit, sample_rate, root_key=60):
"""Añade un sample mono"""
num_points = len(pcm_16bit) // 2
self.samples.append({
'name': self._clean(name, 20),
'pcm': pcm_16bit,
'num_points': num_points,
'sr': sample_rate,
'root': min(127, max(0, root_key)),
'type': 'mono',
'link': 0,
'sf_type': 1, # monoSample
})
return len(self.samples) - 1
def add_sample_stereo(self, name, pcm_left, pcm_right, sample_rate, root_key=60):
"""
Añade un par stereo (left + right).
SF2 guarda stereo como dos samples separados que se enlazan.
"""
num_points_l = len(pcm_left) // 2
num_points_r = len(pcm_right) // 2
left_idx = len(self.samples)
right_idx = left_idx + 1
# Left sample
self.samples.append({
'name': self._clean(name + '_L', 20),
'pcm': pcm_left,
'num_points': num_points_l,
'sr': sample_rate,
'root': min(127, max(0, root_key)),
'type': 'stereo_left',
'link': right_idx,
'sf_type': 4, # leftSample
})
# Right sample
self.samples.append({
'name': self._clean(name + '_R', 20),
'pcm': pcm_right,
'num_points': num_points_r,
'sr': sample_rate,
'root': min(127, max(0, root_key)),
'type': 'stereo_right',
'link': left_idx,
'sf_type': 8, # rightSample
})
return left_idx, right_idx
def add_instrument(self, name, zones):
self.instruments.append({
'name': self._clean(name, 20),
'zones': zones,
})
def add_preset(self, name, preset_num, bank, inst_idx):
self.presets.append({
'name': self._clean(name, 20),
'preset': preset_num,
'bank': bank,
'inst_idx': inst_idx,
})
def build(self):
self._calc_offsets()
info = self._mk_info()
sdta = self._mk_sdta()
pdta = self._mk_pdta()
body = b'sfbk' + info + sdta + pdta
return b'RIFF' + struct.pack('<I', len(body)) + body
def _clean(self, s, n):
return ''.join(c if 32 <= ord(c) < 127 else '_' for c in s)[:n]
def _fstr(self, s, n):
return s.encode('ascii', errors='replace')[:n].ljust(n, b'\x00')
def _schunk(self, tag, s):
b = s.encode('ascii', errors='replace') + b'\x00'
if len(b) % 2:
b += b'\x00'
return tag + struct.pack('<I', len(b)) + b
def _chunk(self, tag, data):
r = tag + struct.pack('<I', len(data)) + data
if len(data) % 2:
r += b'\x00'
return r
def _calc_offsets(self):
off = 0
for s in self.samples:
s['start'] = off
s['end'] = off + s['num_points']
s['loop_s'] = off
s['loop_e'] = off + s['num_points'] - 1
off += s['num_points'] + 46
def _mk_info(self):
sub = b'ifil' + struct.pack('<I', 4) + struct.pack('<HH', 2, 4)
sub += self._schunk(b'isng', 'EMU8000')
sub += self._schunk(b'INAM', self.name)
sub += self._schunk(b'ISFT', 'WAV2SF2 Creator')
return b'LIST' + struct.pack('<I', len(sub) + 4) + b'INFO' + sub
def _mk_sdta(self):
smpl = b''
for s in self.samples:
smpl += s['pcm']
smpl += b'\x00' * 92 # 46 zero samples * 2 bytes
sc = b'smpl' + struct.pack('<I', len(smpl)) + smpl
if len(smpl) % 2:
sc += b'\x00'
return b'LIST' + struct.pack('<I', len(sc) + 4) + b'sdta' + sc
def _mk_pdta(self):
sub = b''
sub += self._chunk(b'phdr', self._mk_phdr())
sub += self._chunk(b'pbag', self._mk_pbag())
sub += self._chunk(b'pmod', b'\x00' * 10)
sub += self._chunk(b'pgen', self._mk_pgen())
sub += self._chunk(b'inst', self._mk_inst())
sub += self._chunk(b'ibag', self._mk_ibag())
sub += self._chunk(b'imod', b'\x00' * 10)
sub += self._chunk(b'igen', self._mk_igen())
sub += self._chunk(b'shdr', self._mk_shdr())
return b'LIST' + struct.pack('<I', len(sub) + 4) + b'pdta' + sub
def _mk_phdr(self):
d = b''
bi = 0
for p in self.presets:
d += self._fstr(p['name'], 20)
d += struct.pack('<HHH', p['preset'], p['bank'], bi)
d += struct.pack('<III', 0, 0, 0)
bi += 1
d += self._fstr('EOP', 20)
d += struct.pack('<HHH', 255, 255, bi)
d += struct.pack('<III', 0, 0, 0)
return d
def _mk_pbag(self):
d = b''
gi = 0
for _ in self.presets:
d += struct.pack('<HH', gi, 0)
gi += 1
d += struct.pack('<HH', gi, 0)
return d
def _mk_pgen(self):
d = b''
for p in self.presets:
d += struct.pack('<HH', 41, p['inst_idx'])
d += struct.pack('<HH', 0, 0)
return d
def _mk_inst(self):
d = b''
bi = 0
for inst in self.instruments:
d += self._fstr(inst['name'], 20)
d += struct.pack('<H', bi)
bi += len(inst['zones'])
d += self._fstr('EOI', 20)
d += struct.pack('<H', bi)
return d
def _mk_ibag(self):
d = b''
gi = 0
for inst in self.instruments:
for z in inst['zones']:
d += struct.pack('<HH', gi, 0)
num_gens = z.get('num_gens', 5)
gi += num_gens
d += struct.pack('<HH', gi, 0)
return d
def _mk_igen(self):
d = b''
for inst in self.instruments:
for z in inst['zones']:
# keyRange (43)
kr = (z['key_hi'] & 0x7F) << 8 | (z['key_lo'] & 0x7F)
d += struct.pack('<HH', 43, kr)
# velRange (44)
d += struct.pack('<HH', 44, 0x7F00)
# sampleModes (54) = 0 no loop
d += struct.pack('<HH', 54, 0)
# overridingRootKey (58)
d += struct.pack('<HH', 58, z['root_key'])
# sampleID (53)
d += struct.pack('<HH', 53, z['sample_idx'])
# Si es stereo, añadir zona para el canal derecho
if z.get('is_stereo', False):
d += struct.pack('<HH', 43, kr)
d += struct.pack('<HH', 44, 0x7F00)
d += struct.pack('<HH', 54, 0)
d += struct.pack('<HH', 58, z['root_key'])
d += struct.pack('<HH', 53, z['right_sample_idx'])
d += struct.pack('<HH', 0, 0)
return d
def _mk_shdr(self):
d = b''
for i, s in enumerate(self.samples):
d += self._fstr(s['name'], 20)
d += struct.pack('<IIIII', s['start'], s['end'],
s['loop_s'], s['loop_e'], s['sr'])
d += struct.pack('<Bb', s['root'], 0)
d += struct.pack('<HH', s['link'], s['sf_type'])
# EOS
d += self._fstr('EOS', 20)
d += struct.pack('<IIIII', 0, 0, 0, 0, 0)
d += struct.pack('<Bb', 0, 0)
d += struct.pack('<HH', 0, 0)
return d
###############################################################################
# LÓGICA PRINCIPAL
###############################################################################
def calculate_key_zones(midi_notes):
"""
Dado un list de notas MIDI ordenadas, calcula el rango de teclas
que cada sample debe cubrir (punto medio entre notas vecinas).
"""
if len(midi_notes) == 1:
return [(0, 127)]
zones = []
sorted_notes = sorted(midi_notes)
for i, note in enumerate(sorted_notes):
if i == 0:
lo = 0
else:
lo = (sorted_notes[i - 1] + note) // 2 + 1
if i == len(sorted_notes) - 1:
hi = 127
else:
hi = (note + sorted_notes[i + 1]) // 2
zones.append((lo, hi))
return zones
def create_sf2_from_samples(sample_list, sf2_name="MySoundFont",
preset_name="Preset", do_normalize=True,
do_trim=True):
"""
Crea un SF2 desde una lista de (nombre, audio_2d, sample_rate, midi_note, num_channels).
SIN compresión. Preserva stereo. Preserva sample rate original.
"""
if not sample_list:
raise ValueError("No se encontraron samples válidos.")
# Ordenar por nota MIDI
sample_list.sort(key=lambda x: x[3])
# Calcular zonas de teclado
midi_notes = [s[3] for s in sample_list]
zones = calculate_key_zones(midi_notes)
writer = SF2Writer(sf2_name)
zone_list = []
for i, (name, audio, sr, midi_note, num_channels) in enumerate(sample_list):
# Normalizar (no cambia el peso, solo el volumen)
if do_normalize:
audio = normalize_audio(audio)
# Recortar silencio
if do_trim:
audio = trim_silence(audio, sr, num_channels)
if len(audio) < 10:
continue
key_lo, key_hi = zones[i]
sample_name = f"{midi_to_note_name(midi_note)}_{name}"[:20]
if num_channels >= 2:
# ── STEREO: guardar como par L/R ──
left_channel = np.ascontiguousarray(audio[:, 0])
right_channel = np.ascontiguousarray(audio[:, 1])
pcm_left = float_to_pcm16(left_channel)
pcm_right = float_to_pcm16(right_channel)
left_idx, right_idx = writer.add_sample_stereo(
sample_name, pcm_left, pcm_right, sr, root_key=midi_note
)
zone_list.append({
'key_lo': key_lo,
'key_hi': key_hi,
'root_key': midi_note,
'sample_idx': left_idx,
'right_sample_idx': right_idx,
'is_stereo': True,
'num_gens': 10, # 5 gens left + 5 gens right
})
else:
# ── MONO ──
mono_data = np.ascontiguousarray(audio[:, 0])
pcm = float_to_pcm16(mono_data)
sample_idx = writer.add_sample_mono(
sample_name, pcm, sr, root_key=midi_note
)
zone_list.append({
'key_lo': key_lo,
'key_hi': key_hi,
'root_key': midi_note,
'sample_idx': sample_idx,
'is_stereo': False,
'num_gens': 5,
})
if not writer.samples:
raise ValueError("Ningún sample válido después del procesamiento.")
writer.add_instrument(preset_name[:20], zone_list)
writer.add_preset(preset_name[:20], 0, 0, 0)
return writer.build(), len(writer.samples), zones
###############################################################################
# INTERFAZ GRADIO
###############################################################################
def process_zip(file, sf2_name, preset_name, do_normalize, do_trim):
if file is None:
return None, "❌ Sube un archivo ZIP con los WAVs."
try:
filepath = file if isinstance(file, str) else file.name
with open(filepath, 'rb') as f:
data = f.read()
original_size = len(data)
if not sf2_name or not sf2_name.strip():
sf2_name = os.path.splitext(os.path.basename(filepath))[0]
if not preset_name or not preset_name.strip():
preset_name = sf2_name
# Abrir ZIP
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except Exception:
return None, "❌ No se pudo abrir como ZIP. Asegúrate de subir un archivo .zip"
# Leer samples
sample_list = []
skipped = []
errors = []
stereo_count = 0
mono_count = 0
audio_extensions = ('.wav', '.wave', '.flac', '.ogg', '.aiff', '.aif')
for entry in sorted(zf.namelist()):
if entry.endswith('/'):
continue
basename = os.path.basename(entry)
if basename.startswith('.') or basename.startswith('__'):
continue
if not basename.lower().endswith(audio_extensions):
continue
# Parsear nota del nombre
midi_note = parse_note_name(basename)
if midi_note is None:
skipped.append(f"⚠️ `{basename}` — no se pudo detectar la nota")
continue
# Leer audio
try:
audio_data = zf.read(entry)
audio_float, sr, num_channels = read_audio_file(audio_data)
if audio_float is None:
errors.append(f"❌ `{basename}` — no se pudo leer el audio")
continue
if len(audio_float) < 100:
errors.append(f"❌ `{basename}` — audio demasiado corto")
continue
if num_channels >= 2:
stereo_count += 1
else:
mono_count += 1
clean_name = os.path.splitext(basename)[0]
sample_list.append((clean_name, audio_float, sr, midi_note, num_channels))
except Exception as e:
errors.append(f"❌ `{basename}` — {str(e)}")
if not sample_list:
msg = "❌ **No se encontraron samples válidos.**\n\n"
msg += "Asegúrate de que los archivos WAV tengan la nota en el nombre:\n"
msg += "`C3.wav`, `F#4.wav`, `Ab3.wav`, `Nylon+Steel_E4_127.wav`, etc.\n\n"
if skipped:
msg += "**Archivos sin nota detectada:**\n"
msg += "\n".join(skipped[:20]) + "\n\n"
if errors:
msg += "**Errores:**\n"
msg += "\n".join(errors[:20])
return None, msg
# Crear SF2
sf2_bytes, num_samples, zones = create_sf2_from_samples(
sample_list, sf2_name, preset_name, do_normalize, do_trim
)
# Guardar
out_name = sf2_name.replace(' ', '_') + '.sf2'
tmp = tempfile.mkdtemp()
out_path = os.path.join(tmp, out_name)
with open(out_path, 'wb') as f:
f.write(sf2_bytes)
# Info
info = f"""✅ **¡SF2 creado exitosamente!**
📁 **ZIP entrada:** {format_size(original_size)}
📁 **SF2 salida:** {out_name} ({format_size(len(sf2_bytes))})
🎵 **Samples incluidos:** {num_samples}
🔊 **Stereo:** {stereo_count} samples
🔈 **Mono:** {mono_count} samples
🎛️ **Sin compresión** — Audio original preservado
🎚️ **Sample rate original** — Sin resampleo
### 🎹 Mapa del teclado:
| Nota | MIDI | Rango de teclas | Tipo |
|------|------|-----------------|------|
"""
sample_list_sorted = sorted(sample_list, key=lambda x: x[3])
for i, (name, _, sr, midi, nch) in enumerate(sample_list_sorted):
if i < len(zones):
lo, hi = zones[i]
lo_name = midi_to_note_name(lo)
hi_name = midi_to_note_name(hi)
ch_type = "🔊 Stereo" if nch >= 2 else "🔈 Mono"
info += f"| **{midi_to_note_name(midi)}** | {midi} | {lo_name} ({lo}) — {hi_name} ({hi}) | {ch_type} |\n"
if skipped:
info += f"\n### ⚠️ Archivos ignorados ({len(skipped)}):\n"
for s in skipped[:10]:
info += f"- {s}\n"
if errors:
info += f"\n### ❌ Errores ({len(errors)}):\n"
for e in errors[:10]:
info += f"- {e}\n"
return out_path, info
except ValueError as e:
return None, f"❌ **Error:** {str(e)}"
except Exception as e:
tb = traceback.format_exc()
return None, f"❌ **Error inesperado:** {str(e)}\n\n```\n{tb}\n```"
def format_size(b):
if b < 1024:
return f"{b} B"
if b < 1048576:
return f"{b / 1024:.1f} KB"
return f"{b / 1048576:.1f} MB"
###############################################################################
# UI
###############################################################################
with gr.Blocks(
title="WAV → SF2 Creator",
theme=gr.themes.Soft(primary_hue="blue", secondary_hue="violet"),
) as demo:
gr.HTML("""
<div style="text-align:center; margin-bottom:0.5em;">
<h1>🎹 WAV → SF2 SoundFont Creator</h1>
<p style="color:#666; font-size:1.1em;">
Sube un ZIP con WAVs nombrados por nota → obtén un SF2 listo para usar
</p>
<p style="color:#888; font-size:0.9em;">
🔊 Preserva stereo • 🎛️ Sin compresión • 📀 Calidad original
</p>
</div>
""")
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("### 📤 Entrada")
input_file = gr.File(
label="ZIP con WAVs (nombrados por nota)",
file_types=[".zip"],
type="filepath",
)
gr.Markdown("""
**📝 Formato de nombres soportado:**
```
C3.wav → Do 3 (MIDI 48)
F#4.wav → Fa# 4 (MIDI 66)
Ab3.wav → Lab 3 (MIDI 56)
Bb2.wav → Sib 2 (MIDI 46)
Piano_G4.wav → Sol 4 (MIDI 67)
Nylon+Steel_E4_127.wav → Mi 4 (MIDI 64)
Guitar_F#3_64.wav → Fa# 3 (MIDI 54)
Bass_Bb2_100.wav → Sib 2 (MIDI 46)
060.wav → MIDI 60
```
""")
sf2_name = gr.Textbox(
label="Nombre del SoundFont",
placeholder="Mi Piano",
value="MySoundFont",
)
preset_name = gr.Textbox(
label="Nombre del Preset",
placeholder="Piano Acústico",
value="Preset 1",
)
gr.Markdown("### ⚙️ Opciones")
with gr.Row():
do_normalize = gr.Checkbox(label="Normalizar audio", value=True)
do_trim = gr.Checkbox(label="Recortar silencio", value=True)
convert_btn = gr.Button(
"🎵 Crear SF2",
variant="primary",
size="lg",
)
with gr.Column(scale=1):
gr.Markdown("### 📥 Resultado")
output_file = gr.File(label="Archivo SF2")
output_info = gr.Markdown("*Sube un ZIP para comenzar...*")
convert_btn.click(
fn=process_zip,
inputs=[input_file, sf2_name, preset_name, do_normalize, do_trim],
outputs=[output_file, output_info],
)
gr.Markdown("""
---
### 💡 ¿Cómo funciona?
1. **Sube un ZIP** con archivos WAV nombrados con la nota musical
2. La app **detecta la nota** de cada archivo por su nombre
3. Cada sample se asigna a su nota MIDI como **root key**
4. Las notas intermedias se cubren automáticamente **ajustando el pitch**
### 🔊 Calidad preservada
- **Stereo** → se guarda como par L/R real en el SF2
- **Mono** → se guarda como mono
- **Sin resampleo** → se mantiene el sample rate original
- **Sin compresión** → PCM 16-bit directo
- **Sin conversión a mono** → tu stereo se queda stereo
### 🎹 Ejemplo de distribución automática
Si subes: `C3.wav`, `F3.wav`, `A#3.wav`, `D#4.wav`, `G4.wav`, `C5.wav`
```
C3 (48) → cubre teclas 0-50 (todo lo grave + hasta D#3)
F3 (53) → cubre teclas 51-55 (E3 hasta G#3)
A#3 (58) → cubre teclas 56-60 (A3 hasta C4)
D#4 (63) → cubre teclas 61-65 (C#4 hasta F4)
G4 (67) → cubre teclas 66-73 (F#4 hasta B4)
C5 (72) → cubre teclas 74-127 (C5 hasta lo más agudo)
```
### 📋 Notas
- **Más samples = mejor calidad** (menos pitch shifting necesario)
- Soporta **WAV, FLAC, OGG, AIFF** dentro del ZIP
- El SF2 es compatible con **FluidSynth, Polyphone, MuseScore, LMMS, y cualquier DAW**
""")
if __name__ == "__main__":
demo.launch()