
Eco: la hermana pequeña de Postal IA, ahora con sonido
Eco es la comprobación, en sonido, de la misma idea que ya defendía Postal IA en imagen: que algo generado con ayuda de una IA suene bien no tiene por qué significar depender de un modelo de audio, de pago o gratuito. Es la hermana pequeña de Postal IA —mismo centro, mismo profesor, mismo modelo local en ROCKY vía LM Studio, y aun así un proyecto independiente, sin una sola línea de código compartida— construida para hacer una sola cosa: a partir de un texto o un tema, pedirle al modelo una lectura emocional breve y traducir esa lectura en una melodía corta que suena en el propio navegador.
El reparto de tareas: quién interpreta y quién compone
El principio de MODULARIDAD del proyecto separa tres piezas que no se tocan entre sí: el modelo de lenguaje lee el texto y devuelve un ánimo (de una lista cerrada de cinco) y de 3 a 5 palabras clave; un algoritmo determinista en Python, sin ninguna llamada más al modelo, traduce esa lectura en una secuencia de notas; y el navegador, con la Web Audio API integrada en cualquier navegador moderno, reproduce esas notas sin ninguna librería de audio externa y sin generar ni guardar ningún archivo de sonido. La parte creativa —interpretar el ánimo de un texto— la hace el modelo, porque es lo que se le da bien. La parte de “componer” la hace código, porque así la relación entre lo que se escribe y lo que suena es explicable y reproducible: el mismo texto de entrada da siempre la misma melodía, no una caja negra que a veces suena bien y a veces no se sabe por qué.
Qué se ha reutilizado de Postal IA, y qué es nuevo
De main.py de Postal IA se ha tomado el patrón entero de backend: FastAPI sirviendo su propia interfaz desde /static para no lidiar con CORS, el mismo manejo de errores de conexión con LM Studio (si está cerrado o tarda demasiado, la API responde con un mensaje claro), el mismo filtro básico de mensajes sospechosos, el mismo límite de 20 peticiones por minuto con slowapi, y el mismo esquema de código de acceso opcional por variable de entorno (ECO_TOKEN en vez de POSTALIA_TOKEN, mecanismo idéntico). Arranca en el puerto 8012 en vez del 8002 de Postal IA, para poder tener los dos abiertos a la vez en la misma máquina.
Lo nuevo es todo lo que tiene que ver con música. melodia.py es una pieza sin equivalente en Postal IA salvo en espíritu: cumple el mismo papel que metrica.py (una función pura, con reglas, separada a propósito del resto), pero en vez de contar sílabas de un romance ya escrito, aquí compone desde cero. Y el system prompt pide algo muy distinto de un micropoema: un objeto JSON con una estructura fija, con una temperatura baja (0,4 frente al 0,9 de los poemas) porque aquí no interesa originalidad literaria, sino una lectura consistente.
python
ANIMOS_DISPONIBLES = ["alegre", "melancólico", "misterioso", "enérgico", "calmado"]
SYSTEM_PROMPT_ANALISIS = (
"Eres un analizador de textos breves en espanol. Cuando te doy un texto "
"o un tema, respondes UNICAMENTE con un objeto JSON, sin explicaciones, "
"sin bloques de codigo y sin ningun texto antes o despues, con "
"exactamente esta forma:\n"
'{"animo": "...", "palabras_clave": ["...", "...", "..."]}\n\n'
"El campo \"animo\" debe ser EXACTAMENTE una de estas cinco palabras: "
+ ", ".join(ANIMOS_DISPONIBLES) + ". Elige la que mejor describa el "
"animo o la emocion dominante del texto.\n"
"El campo \"palabras_clave\" debe ser una lista de entre 3 y 5 palabras "
"sueltas (sustantivos o adjetivos, en minusculas, sin repetir) que "
"resuman las ideas o imagenes mas importantes del texto."
)
Como un modelo pequeño no siempre respeta “solo JSON, nada más” a la primera —a veces envuelve la respuesta en un bloque de código markdown pese a que se le pide lo contrario—, hay un rescate en dos pasos antes de darse por vencido: quitar el envoltorio si aparece, y si aun así no es JSON válido, buscar el primer bloque {...} del texto.
Cómo se traduce eso en notas, sin ninguna IA de por medio
melodia.py recibe el ánimo y las palabras clave y no vuelve a tocar el modelo para nada. El ánimo elige, de una tabla fija, una escala musical, un tempo y un timbre:
python
ANIMOS = {
"alegre": {"escala": [0, 2, 4, 5, 7, 9, 11], "nombre_escala": "mayor", "tempo_bpm": 118, "forma_onda": "triangle"},
"melancolico": {"escala": [0, 2, 3, 5, 7, 8, 10], "nombre_escala": "menor natural", "tempo_bpm": 70, "forma_onda": "sine"},
"misterioso": {"escala": [0, 1, 3, 5, 7, 8, 10], "nombre_escala": "frigia", "tempo_bpm": 86, "forma_onda": "sawtooth"},
"energico": {"escala": [0, 2, 4, 7, 9], "nombre_escala": "pentatonica mayor", "tempo_bpm": 152, "forma_onda": "square"},
"calmado": {"escala": [0, 2, 4, 7, 9], "nombre_escala": "pentatonica mayor", "tempo_bpm": 58, "forma_onda": "sine"},
}
Cada palabra clave se convierte en un número reproducible con un hash polinómico escrito a mano, no con el hash() integrado de Python —que Python aleatoriza a propósito entre ejecuciones, por seguridad, así que dos arranques del programa darían resultados distintos con la misma palabra—:
python
def _valor_deterministico(texto):
valor = 0
for caracter in _normalizar(texto):
valor = (valor * 31 + ord(caracter)) % 100003
return valor
def _semitonos_desde_valor(valor, escala):
total_grados = len(escala) * 2 # dos octavas de margen
indice = valor % total_grados
octava, grado = divmod(indice, len(escala))
return escala[grado] + 12 * octava
Y en vez de una sola nota por palabra clave —lo que se quedaba corto, como se verá más abajo—, cada palabra aporta una nota por cada sílaba aproximada que tiene, contada por grupos de vocales seguidas. Una palabra larga como “extraordinario” da más notas que “sol”, igual que dura más al pronunciarla; la duración total de la palabra se reparte entre sus sílabas, así que la melodía gana movimiento sin alargarse sin control. Al final, siempre hay una nota extra que vuelve a la tónica —una regla fija de cierre, no derivada de ninguna palabra— para que la pieza suene resuelta y no se quede a medias.
La reproducción, ya en static/index.html, es puramente mecánica: no decide nada, solo coge la lista de {frecuencia_hz, duracion_s} que ha devuelto el backend y la reproduce con un oscilador por nota, con una pequeña envolvente de volumen para evitar los “clics” de encender y apagar un oscilador de golpe.
javascript
const oscilador = audioContext.createOscillator();
oscilador.type = datos.forma_onda;
oscilador.frequency.value = nota.frecuencia_hz;
const ganancia = audioContext.createGain();
ganancia.gain.setValueAtTime(0, inicio);
ganancia.gain.linearRampToValueAtTime(volumen, inicio + ataque);
ganancia.gain.setValueAtTime(volumen, Math.max(inicio + ataque, fin - liberacion));
ganancia.gain.linearRampToValueAtTime(0, fin);
oscilador.connect(ganancia);
ganancia.connect(audioContext.destination);
oscilador.start(inicio);
oscilador.stop(fin + 0.02);
Encima de esa reproducción hay un pequeño “piano roll” en SVG: un rectángulo por nota, con el ancho según su duración y la altura según su frecuencia, que se resalta en el momento exacto en que suena cada nota. No decide nada tampoco —solo dibuja los datos que ya llegaron del backend—, pero ayuda a ver por qué suena como suena.
Un fallo real: notas a casi 20 kHz
Nada más probar el algoritmo con datos simulados —sin esperar a tener LM Studio a mano—, algo no encajaba: las frecuencias de algunas notas eran absurdas, más de 8000 Hz, muy por encima de lo audible como melodía. La causa estaba en esta línea:
python
grado, octava = divmod(indice, len(escala)) # mal: al revés
divmod(indice, len(escala)) devuelve primero el cociente y después el resto, así que grado se quedaba con el cociente (siempre 0 o 1) y octava con el resto (hasta 6, en una escala de 7 notas). El resultado: solo se usaban dos de los siete grados de la escala, y esas notas se desplazaban hasta seis octavas —72 semitonos— por encima de la tónica. Las pruebas automáticas no lo detectaron, porque comprobaban si la nota pertenecía a la escala mirando solo el resto módulo 12, algo que seguía siendo cierto incluso con el error. Se corrigió invirtiendo la asignación:
python
octava, grado = divmod(indice, len(escala)) # bien
y se reforzaron las pruebas para comprobar el semitono absoluto, no solo su resto módulo 12, y para exigir que con varias palabras se usen de verdad más de dos grados distintos de la escala. La lección, aplicable más allá de este proyecto: con hashes y módulos, conviene probar también los valores absolutos, no solo invariantes que puedan cumplirse por casualidad aunque el cálculo esté mal.
De una nota por palabra a una nota por sílaba
La primera versión daba exactamente una nota por palabra clave más la de cierre: con 3 a 5 palabras, siempre entre 4 y 6 notas, sin relación con lo que se hubiera escrito. Al probarlo sonaba correcto pero corto. La solución no fue pedirle al modelo más palabras clave —eso habría roto el criterio del MVP, que fija 3 a 5— sino sacarle más partido a las que ya llegan: cada palabra se cuenta por sílabas aproximadas y aporta una nota por cada una. El mismo texto, con las mismas 3 palabras clave, pasó de 4 notas a 11.
Cómo se instala y se arranca
cd ecoia
pip install -r requirements.txt
uvicorn main:app --reload --port 8012
y abrir http://127.0.0.1:8012 con LM Studio abierto y su servidor local activado. Se escribe un texto o un tema en el cuadro, se pulsa «Componer melodía», y aparecen el ánimo detectado, la escala, el tempo, las palabras clave y el piano roll. El botón «Reproducir» hace sonar la melodía; «Detener» la corta en cualquier momento. Si el centro pide un código de acceso, se define la variable ECO_TOKEN antes de arrancar, igual que POSTALIA_TOKEN en Postal IA.
Cómo se ha comprobado
Todo, hasta ahora, sin un LM Studio real delante: test_melodia.py (18 pruebas, sin ningún framework, igual estilo que test_metrica.py) comprueba que el algoritmo de composición es determinista, que usa de verdad toda la escala, que siempre cierra en la tónica y que un ánimo no reconocido no rompe nada. Y el backend completo se ha probado con un LM Studio de mentira que devuelve siempre la misma respuesta: petición vacía, demasiado larga o sospechosa rechazada con el mensaje correcto; JSON limpio y JSON envuelto en un bloque de código, ambos aceptados; mismo texto, dos peticiones seguidas, exactamente la misma melodía.
Qué no hace todavía (límites honestos)
- No se ha probado aún con un LM Studio real en ROCKY: falta la prueba de fuego con el modelo de verdad, y con ella confirmar que devuelve JSON razonablemente bien formado con el prompt actual.
- El código de acceso (
ECO_TOKEN) existe pero está desactivado por defecto, igual que en Postal IA: no pide nada en local. - Esto no es composición musical con criterio armónico real, ni una IA generativa de audio: es una traducción sencilla y explicable de palabras a notas dentro de una escala coherente. Conviene decirlo tal cual en la propia interfaz, y ya está dicho ahí.
- El conteo de sílabas es una aproximación simple (grupos de vocales), no el contador riguroso que Postal IA usa para medir versos: aquí basta para decidir cuántas notas le tocan a una palabra, no para métrica de verdad.
Siguiente paso
Que José Luis pruebe Eco con LM Studio real en ROCKY, con varios textos seguidos, y valore si el ánimo detectado y el número de notas por melodía convencen para llevarlo a clase. Si el resultado gusta, ajustar aquí —escalas, tempos, cuántas sílabas cuenta como nota— es mucho más barato que rehacer nada.


