Spaces:
Paused
Paused
| 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() |