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'(? 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('= 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("""

🎹 WAV → SF2 SoundFont Creator

Sube un ZIP con WAVs nombrados por nota → obtén un SF2 listo para usar

🔊 Preserva stereo • 🎛️ Sin compresión • 📀 Calidad original

""") 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()