Tengo una librería musical de 14,500 tracks acumulada durante años. Está razonablemente organizada en carpetas por artista y álbum, pero más allá de eso es una caja negra: no sé qué tan diferente suenan dos tracks de géneros parecidos, no puedo buscar "algo melancólico con bajo BPM para las 2am", y crear playlists temáticas es un trabajo manual que nunca hago.
La solución obvia es clasificar todo automáticamente con IA. Este post describe cómo construí el pipeline completo: desde cero, corriendo en mi propio hardware, sin depender de servicios externos para el análisis de audio.
El modelo: Music Flamingo
Music Flamingo es un modelo de 8B parámetros de NVIDIA entrenado específicamente para entender audio musical. A diferencia de modelos de transcripción como Whisper, Music Flamingo puede responder preguntas sobre el contenido musical: género, mood, BPM, tonalidad, instrumentos, estilo de producción.
La interfaz es un modelo multimodal al estilo LLaMA: le pasas un archivo de audio y un prompt de texto, y genera una respuesta en lenguaje natural.
# El modelo acepta audio + texto y genera análisis
response = model.generate(
audio=audio_waveform,
prompt="Identify the genre, subgenre, and musical era of this track. "
"Return only valid JSON: {\"genre\": \"\", \"subgenre\": \"\", \"era\": \"\"}",
)
Cuatro prompts por track: género/subgénero/era, mood/energía/atmósfera, características técnicas (BPM, tonalidad, instrumentos), y descripción libre en prosa.
El reto: AMD ROCm en RDNA4
Mi servidor corre una RX 9070 XT (arquitectura RDNA4, gfx1201). El problema: la 9070 XT salió en 2025 y el soporte en el ecosistema ROCm/PyTorch todavía es parcial.
El primer intento falló con:
HSA_STATUS_ERROR_OUT_OF_RESOURCES
ROCm no podía inicializar la GPU sin un override de versión. El proceso de encontrar el valor correcto requirió tres iteraciones:
| Override | Resultado |
|---|---|
| Sin override | HSA_STATUS_ERROR_OUT_OF_RESOURCES — GPU no inicializa |
HSA_OVERRIDE_GFX_VERSION=11.0.0 |
GPU inicializa, pero los kernels gfx1100 fallan en hardware gfx1201 con HSA_STATUS_ERROR_EXCEPTION |
HSA_OVERRIDE_GFX_VERSION=12.0.1 |
✅ Funciona — usa kernels nativos gfx1201 |
El contenedor Docker termina con esta variable en el environment:
environment:
- HSA_OVERRIDE_GFX_VERSION=12.0.1
- QUANT=int8
devices:
- /dev/kfd
- /dev/dri/renderD128
La cuantización INT8 reduce el modelo de ~16GB a ~8GB de VRAM, cabe holgado en los 16GB de la tarjeta.
Arquitectura del pipeline
flowchart TD
FS["Sistema de archivos\n/mnt/user/Music\n14,500 tracks"]
SC["scanner.py\nregistra tracks en DB"]
PG[("PostgreSQL 18 + pgvector\ntracks · track_analysis\nlyrics · track_embeddings")]
WK["worker.py\ncola de jobs con\nFOR UPDATE SKIP LOCKED"]
subgraph GPU["GPU — RX 9070 XT (221W cap)"]
MF["Music Flamingo\nFlask API :8181\nINT8 · ROCm 7.2.3"]
end
subgraph CLOUD["Cloud"]
OAI["OpenAI\ntext-embedding-3-small\n1536 dims"]
end
subgraph OLLAMA["Ollama — CPU/GPU"]
GEM["gemma4:31b-cloud\nletras: traducción\ny análisis semántico"]
end
WEB["DuckDuckGo + Genius\nAZLyrics scraping"]
FS --> SC --> PG
PG --> WK
WK -->|"music_analysis"| MF
WK -->|"lyrics_fetch"| WEB
WK -->|"lyrics_translate\nlyrics_process"| GEM
WK -->|"embeddings_*"| OAI
MF --> PG
WEB --> PG
GEM --> PG
OAI --> PG
El worker no es un sistema de colas complejo — es un loop Python que hace SELECT FOR
UPDATE SKIP LOCKED en una tabla de jobs. Simple, robusto, reiniciable en cualquier
momento sin perder trabajo.
Los 8 tipos de job
Cada track pasa por una cadena de jobs en orden de dependencia:
music_analysis
├── embeddings_audio
├── embeddings_description
└── lyrics_fetch
└── lyrics_translate
└── lyrics_process
└── embeddings_lyrics
└── embeddings_combine
| Job | Qué hace | Modelo |
|---|---|---|
music_analysis |
Género, mood, BPM, tonalidad, descripción | Music Flamingo (GPU) |
lyrics_fetch |
Busca y scrapea letra del track | DuckDuckGo + Genius/AZLyrics |
lyrics_translate |
Traduce al inglés si no está en inglés | gemma4:31b-cloud |
lyrics_process |
Keywords, tópicos, sentiment, temas | gemma4:31b-cloud |
embeddings_audio |
Vector del análisis musical | OpenAI text-embedding-3-small |
embeddings_description |
Vector de la descripción en prosa | OpenAI text-embedding-3-small |
embeddings_lyrics |
Vector de letra + semántica | OpenAI text-embedding-3-small |
embeddings_combine |
Promedio ponderado de los tres vectores | Python puro |
El resultado final en PostgreSQL por cada track:
-- Análisis musical completo
SELECT genre, subgenre, era, genre_tags,
mood, energy, atmosphere, intensity,
bpm, musical_key, mode, instruments,
production_style, mix_notes, description
FROM track_analysis WHERE track_id = 42;
-- Tres vectores de 1536 dims para similaridad semántica
SELECT audio_embedding, description_embedding, combined_embedding
FROM track_embeddings WHERE track_id = 42;
Schema de la base de datos
-- Tracks registrados por el scanner
CREATE TABLE tracks (
id SERIAL PRIMARY KEY,
file_path TEXT UNIQUE NOT NULL,
artist TEXT, title TEXT, album TEXT,
duration REAL, file_format TEXT,
scanned_at TIMESTAMPTZ DEFAULT now()
);
-- Análisis del modelo de audio
CREATE TABLE track_analysis (
track_id INT PRIMARY KEY REFERENCES tracks(id),
genre TEXT, subgenre TEXT, era TEXT, genre_tags TEXT[],
mood TEXT[], energy INT, atmosphere TEXT,
emotional_arc TEXT, intensity TEXT,
bpm REAL, bpm_confidence TEXT, musical_key TEXT, mode TEXT,
instruments TEXT[], production_style TEXT,
mix_notes TEXT, tempo_feel TEXT,
description TEXT, description_embedding vector(1536),
raw_responses JSONB, analyzed_at TIMESTAMPTZ
);
-- Letras y análisis semántico
CREATE TABLE lyrics (
track_id INT PRIMARY KEY REFERENCES tracks(id),
lyrics_original TEXT, lyrics_english TEXT, language TEXT,
keywords TEXT[], topics TEXT[], themes TEXT[], sentiment TEXT,
lyrics_embedding vector(1536),
keywords_embedding vector(1536),
topics_embedding vector(1536)
);
-- Embeddings consolidados
CREATE TABLE track_embeddings (
track_id INT PRIMARY KEY REFERENCES tracks(id),
audio_embedding vector(1536),
description_embedding vector(1536),
combined_embedding vector(1536),
model_used TEXT
);
El problema de la PSU
El servidor tiene una fuente de alimentación con margen justo. La RX 9070 XT tiene un TDP de 304W y puede burstar hasta 340W. Con el pipeline corriendo, la GPU oscilaba entre 250-317W y el servidor se reiniciaba en momentos de pico.
La solución es sysfs — el driver AMD expone un archivo para limitar la potencia:
# Límite mínimo que permite el driver: 221W
echo 221000000 > /sys/class/drm/card0/device/hwmon/hwmon1/power1_cap
Para que sea persistente en cada boot de Unraid, el script /boot/config/go:
sleep 10 # esperar a que cargue el driver amdgpu
HWMON=$(grep -rl '^amdgpu$' /sys/class/hwmon/*/name 2>/dev/null | head -1 | xargs dirname)
if [ -n "$HWMON" ]; then
echo 221000000 > "$HWMON/power1_cap"
fi
221W es una reducción del 30% respecto al default. El análisis tarda un poco más por track (~90 segundos vs ~70), pero el sistema es estable.
Embeddings: por qué OpenAI en vez de local
La primera versión usaba nomic-embed-text en Ollama, corriendo en la misma GPU que
Music Flamingo. El problema: dos modelos compitiendo por 16GB de VRAM generaba
contención — cuando Ollama cargaba el modelo de embeddings mientras Flamingo
analizaba audio, los tiempos se disparaban y la presión sobre la PSU aumentaba.
La solución fue mover los embeddings a OpenAI text-embedding-3-small:
def get_embedding(text: str) -> list[float]:
resp = requests.post(
"https://api.openai.com/v1/embeddings",
headers={"Authorization": f"Bearer {config.OPENAI_API_KEY}"},
json={"model": "text-embedding-3-small", "input": text, "dimensions": 1536},
timeout=60,
)
resp.raise_for_status()
return resp.json()["data"][0]["embedding"]
Costo estimado para clasificar los 14,500 tracks completos: ~$0.50 USD. La GPU queda dedicada 100% a Music Flamingo.
Primeros resultados: 40 tracks analizados
Con AFI, Deftones y Sopor Aeternus como artistas iniciales, algunos patrones interesantes:
El modelo diferencia los remixes correctamente. White Pony 20th Anniversary tiene 14 remixes. El modelo no los trata como "metal alternativo" genérico — los clasifica según lo que realmente suenan:
| Track | Clasificación |
|---|---|
| Korea (Trevor Jackson Remix) | Techno / Ambient, energía 1/10 |
| Elite (Blanck Mass Remix) | Aggressive Industrial Metal, energía 10/10 |
| Digital Bath (DJ Shadow Remix) | Ambient Electronic / Downtempo |
| Teenager (Robert Smith Remix) | Ethereal Ambient |
| Feiticeira original | Thrash Metal |
| Feiticeira (Clams Casino Remix) | Industrial Noise |
Sopor Aeternus es 100% minor. Los 8 tracks analizados del álbum Dead Lovers' Sarabande terminaron en tonalidad menor sin excepción. Tiene sentido para neoclassical darkwave.
BPM = 0 en tracks sin batería definida. Pieces como "Across The Bridge" de Sopor o "Elite (Blanck Mass)" tienen ritmos tan irregulares o capas tan abstractas que el modelo devuelve 0. Es un indicador útil de "ritmo ambiguo".
Lo que viene: búsqueda por similaridad
El objetivo final es poder hacer queries como:
-- "Dame los 10 tracks más similares a esta canción"
SELECT t.artist, t.title,
1 - (te.combined_embedding <=> $1::vector) AS similarity
FROM track_embeddings te
JOIN tracks t ON te.track_id = t.id
ORDER BY te.combined_embedding <=> $1::vector
LIMIT 10;
-- "Tracks melancólicos, bajo BPM, en menor, energía < 5"
SELECT t.artist, t.title, a.bpm, a.energy, a.mood
FROM track_analysis a
JOIN tracks t ON a.track_id = t.id
WHERE a.mode = 'minor'
AND a.energy < 5
AND a.bpm < 90
AND 'melancholic' = ANY(a.mood)
ORDER BY a.energy ASC;
La columna combined_embedding es el promedio de los vectores de audio, descripción
y letra — captura tanto el sonido como el contenido semántico. Con pgvector y un
índice HNSW, la búsqueda entre 14,500 tracks es prácticamente instantánea.
El pipeline completo debería terminar en ~48 horas a ritmo de ~1 track/minuto. Después de eso: generador de playlists.
El stack completo
| Componente | Tecnología |
|---|---|
| Servidor | Unraid — AMD Ryzen + RX 9070 XT 16GB |
| Modelo de audio | nvidia/music-flamingo-hf (8B, INT8, ROCm 7.2.3) |
| API de inferencia | Flask + Python |
| Base de datos | PostgreSQL 18 + pgvector |
| Cola de jobs | Tabla SQL con FOR UPDATE SKIP LOCKED |
| LLM para letras | gemma4:31b-cloud vía Ollama |
| Embeddings | OpenAI text-embedding-3-small (1536 dims) |
| Scraping de letras | DuckDuckGo + Genius + AZLyrics |
| Contenedores | Docker en Unraid con templates XML |
| Control de código | Git en /Code/music_model |