Clasificando 14,500 tracks con IA: Music Flamingo, pgvector y AMD ROCm


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