Módulo 8 · Lenguaje y audio

Lección 4 de 5

Audio con modelos preentrenados

En esta páginaEl problema

El problema

Un micrófono graba clips de medio segundo con 8000 muestras por segundo. Cuatro clips ya tienen etiqueta: en dos suena una alarma, que es un pitido, y en los otros dos solo se oye un ventilador. De cada clip tienes la onda como un arreglo de números y dos estadísticos:

clipcontenidomuestrasmediadesviación estándar
Aalarma4000-0.0010.357
Bventilador40000.0060.362
Calarma40000.0010.288
Dventilador40000.0010.278

La media es casi 0 en los cuatro clips. Si ordenas de mayor a menor desviación estándar, el orden es B, A, C, D: las dos alarmas quedan entre los dos ventiladores. Ningún umbral sobre esa columna deja las dos alarmas de un lado y los dos ventiladores del otro.

Señal muestreada

Un micrófono mide la presión del aire. El audio digital guarda esa medida a intervalos fijos. La frecuencia de muestreo fs es el número de muestras por segundo. Un clip mono es un arreglo de una dimensión, y su duración en segundos es el número de muestras entre fs. Un tono puro de frecuencia f es un seno: amplitud · sin(2π · f · t), con t en segundos.

En la celda, np.arange(n) / fs da el instante en segundos de cada una de las n muestras.

Pregunta. Con fs = 8000, ¿qué forma tiene un clip de 0.5 s? ¿Cuántas muestras dura un periodo completo de un tono de 1000 Hz? Escribe los primeros nueve valores de un tono de 1000 Hz con amplitud 0.5. ¿Qué forma tendría el mismo medio segundo con fs = 16000?

Python 3 · ejemplo reproducible
import numpy as np

fs = 8000
duracion = 0.5
t = np.arange(int(fs * duracion)) / fs
tono = 0.5 * np.sin(2 * np.pi * 1000 * t)

print(tono.shape, tono.dtype)
print(len(tono) / fs)
print(np.round(tono[:9], 3) + 0.0)  # + 0.0 convierte -0. en 0.
print(np.arange(int(16000 * duracion)).shape)
Salida esperada

La salida esperada está oculta.

Con 8 muestras por periodo, el tono de 1000 Hz se reconoce. Un tono de 7000 Hz tendría poco más de una muestra por periodo.

Pregunta. Generas un tono de 7000 Hz con amplitud 0.5 y fs = 8000. ¿Cómo serán sus primeros nueve valores comparados con los del tono de 1000 Hz?

Ver resultado

La tabla da las primeras nueve muestras de cada tono con amplitud 0.5 y fs = 8000, redondeadas a tres decimales. La columna «tono» es la frecuencia generada y la otra columna lista sus muestras en orden. Por ejemplo, la fila de 7000 Hz dice que la segunda muestra de ese tono vale -0.354.

tonoprimeros nueve valores
1000 Hz[ 0. 0.354 0.5 0.354 0. -0.354 -0.5 -0.354 0. ]
7000 Hz[ 0. -0.354 -0.5 -0.354 0. 0.354 0.5 0.354 0. ]

np.allclose compara las 4000 muestras del tono de 7000 Hz con las de -tono y da True.

Lectura. Las muestras del tono de 7000 Hz son las del tono de 1000 Hz con el signo cambiado. Después de muestrear, las dos señales tienen la misma frecuencia: 1000 Hz. Este efecto se llama aliasing. Con fs muestras por segundo solo se representan frecuencias menores que fs / 2, la frecuencia de Nyquist. Con 8000 Hz, el límite es 4000 Hz; un tono de 7000 Hz aparece en 8000 − 7000 = 1000 Hz.

Los cuatro clips del problema son sintéticos. Una alarma es un seno con una fase inicial al azar y un poco de ruido de fondo. Un ventilador es ruido blanco: cada muestra se toma de una distribución normal, independiente de las demás.

La celda plegada define hacer_tono y hacer_ruido, que generan una alarma y un ventilador con la fs de la lección, y crea los cuatro clips con la semilla 0.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
def hacer_tono(frecuencia, amplitud, duracion, rng, ruido=0.05):
    t = np.arange(int(fs * duracion)) / fs
    fase = rng.uniform(0, 2 * np.pi)
    onda = amplitud * np.sin(2 * np.pi * frecuencia * t + fase)
    return onda + rng.normal(0, ruido, t.size)


def hacer_ruido(desviacion, duracion, rng):
    return rng.normal(0, desviacion, int(fs * duracion))


rng = np.random.default_rng(0)
clips = {
    "A": hacer_tono(1000, 0.5, 0.5, rng),
    "B": hacer_ruido(0.36, 0.5, rng),
    "C": hacer_tono(700, 0.4, 0.5, rng),
    "D": hacer_ruido(0.28, 0.5, rng),
}

clips es un diccionario con las ondas de la tabla del problema, con claves de "A" a "D".

Pregunta. ¿Qué desviación estándar tiene un seno de amplitud 0.5 que dura varios periodos? ¿Por qué el ruido del clip B, con desviación estándar 0.36, puede sonar más fuerte que la alarma A?

Ver resultado

La frase compara la desviación estándar de tono, el seno de 1000 Hz y amplitud 0.5 de la primera celda, con la amplitud dividida entre √2. Los valores de los cuatro clips son los de la tabla del problema: por ejemplo, la fila de B dice que su desviación estándar es 0.362, y la de A, 0.357.

tono.std() da 0.354 y 0.5 / √2 da 0.354.

Lectura. La desviación estándar de un seno completo es amplitud / √2: 0.354 para amplitud 0.5. El ruido de fondo sube la de A a 0.357. La desviación estándar mide la energía del clip, sin importar cómo se reparte esa energía en el tiempo. B tiene un poco más de energía que A, así que suena un poco más fuerte. La diferencia entre las clases está en el patrón de la onda: el seno repite un periodo fijo y el ruido no repite nada.

Espectrograma

La transformada de Fourier descompone un fragmento de señal en frecuencias. Un espectrograma aplica esa transformada a ventanas cortas y consecutivas de la señal. Aquí cada ventana tiene n_fft = 256 muestras (32 ms) y la siguiente empieza salto = 128 muestras después. La ventana de Hann suaviza los bordes de cada fragmento. torch.stft devuelve números complejos; su magnitud al cuadrado es la potencia de cada frecuencia en cada ventana.

La transformada de 256 muestras da 256 / 2 + 1 = 129 frecuencias, de 0 Hz a la frecuencia de Nyquist, separadas por 8000 / 256 = 31.25 Hz. Con center=False, el número de ventanas es 1 + (n − n_fft) // salto.

np.fft.rfftfreq(n_fft, d=1 / fs) devuelve esas 129 frecuencias en Hz. La función espectrograma recibe un clip de NumPy y devuelve su potencia P. hop_length es el salto entre ventanas y return_complex=True pide la salida compleja.

Python 3 · ejemplo reproducible
import torch

n_fft, salto = 256, 128
ventana = torch.hann_window(n_fft, dtype=torch.float64)
frecuencias = np.fft.rfftfreq(n_fft, d=1 / fs)


def espectrograma(x):
    Z = torch.stft(
        torch.from_numpy(x),
        n_fft=n_fft,
        hop_length=salto,
        window=ventana,
        center=False,  # sin relleno: la primera ventana empieza en la muestra 0
        return_complex=True,
    )
    return (Z.abs() ** 2).numpy()

P.argmax(axis=0) recorre el eje de frecuencias y da, para cada ventana, el índice de la frecuencia con más potencia. np.unique deja las frecuencias distintas entre esos máximos.

Pregunta. ¿Qué forma tiene la potencia P de un clip de 4000 muestras? En cada ventana, ¿en qué frecuencia esperas el máximo de A y de C? ¿Y en B y D? ¿Cuántas frecuencias distintas aparecen como máximo a lo largo de las ventanas de cada clip?

Python 3 · ejemplo reproducible
for nombre, x in clips.items():
    P = espectrograma(x)
    picos = frecuencias[P.argmax(axis=0)]  # frecuencia del máximo en cada ventana
    print(nombre, P.shape, picos[:5], len(np.unique(picos)))
Salida esperada

La salida esperada está oculta.

Características por ventana

Un clasificador clásico necesita un vector de la misma longitud para cada clip. El espectrograma no lo es: su número de ventanas crece con la duración. Una solución es calcular pocas características en cada ventana y promediarlas a lo largo del tiempo. Aquí se usan dos:

  • centroide espectral: el promedio de las frecuencias ponderado por su potencia
  • fracción de pico: la potencia de la frecuencia más fuerte dividida entre la potencia total de la ventana

caracteristicas_por_ventana calcula las dos con .sum(axis=0) y .max(axis=0), que reducen el eje de frecuencias y dejan un valor por ventana. resumen promedia cada una sobre las ventanas y devuelve un vector de dos números.

Pregunta. ¿Qué centroide esperas para el tono A? El ruido blanco reparte la potencia por igual entre 0 y 4000 Hz: ¿qué centroide esperas para B? ¿La fracción de pico del ruido estará cerca de 1 o cerca de 1 / 129?

Python 3 · ejemplo reproducible
def caracteristicas_por_ventana(x):
    P = espectrograma(x)
    # frecuencias[:, None] tiene forma (129, 1) y pondera cada fila de P
    centroide = (frecuencias[:, None] * P).sum(axis=0) / P.sum(axis=0)
    fraccion_pico = P.max(axis=0) / P.sum(axis=0)
    return centroide, fraccion_pico


def resumen(x):
    centroide, fraccion_pico = caracteristicas_por_ventana(x)
    return np.array([centroide.mean(), fraccion_pico.mean()])


for nombre, x in clips.items():
    centroide, _ = caracteristicas_por_ventana(x)
    vector = resumen(x)
    print(nombre, centroide.shape, round(vector[0], 1), round(vector[1], 3))
Salida esperada

La salida esperada está oculta.

largo es una alarma nueva de 1200 Hz, amplitud 0.3 y 0.8 s, generada con hacer_tono(1200, 0.3, 0.8, rng).

Pregunta. ¿Cuántas ventanas tiene el espectrograma de un clip de 0.8 s y qué forma tiene su vector de características?

Ver resultado

La frase da la forma de la potencia P de largo, con frecuencias en el eje 0 y ventanas en el eje 1, y la forma del vector que devuelve resumen(largo).

El espectrograma de largo tiene forma (129, 49) y su resumen, (2,).

Lectura. El clip de 0.8 s tiene 49 ventanas, pero su resumen tiene forma (2,), igual que los clips de 0.5 s.

Clasificador diminuto

Ahora hay 200 clips nuevos: la mitad alarmas y la mitad ventiladores. Cada clip dura entre 0.3 y 0.8 s. Cada alarma tiene una frecuencia entre 300 y 3000 Hz, una amplitud entre 0.15 y 0.6 y ruido de fondo entre 0.02 y 0.2. Cada ventilador tiene una desviación estándar entre 0.1 y 0.45. Una regresión logística se entrena con una sola característica a la vez: la desviación estándar de la onda, el centroide o la fracción de pico. La partición deja 30 % de los clips para prueba.

La celda plegada genera los 200 clips con la semilla 1. Los clips pares son alarmas y los impares, ventiladores.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
rng_datos = np.random.default_rng(1)
ondas, etiquetas, detalles = [], [], []
for i in range(200):
    duracion_clip = rng_datos.uniform(0.3, 0.8)
    if i % 2 == 0:
        frecuencia = rng_datos.uniform(300, 3000)
        amplitud = rng_datos.uniform(0.15, 0.6)
        ruido = rng_datos.uniform(0.02, 0.2)
        x = hacer_tono(frecuencia, amplitud, duracion_clip, rng_datos, ruido=ruido)
        detalles.append((round(frecuencia), round(amplitud, 2), round(ruido, 2)))
    else:
        x = hacer_ruido(rng_datos.uniform(0.1, 0.45), duracion_clip, rng_datos)
        detalles.append(None)
    ondas.append(x)
    etiquetas.append(int(i % 2 == 0))

y = np.array(etiquetas)

ondas guarda los 200 clips, cada uno con su propia duración. y vale 1 en las alarmas y 0 en los ventiladores. detalles guarda la frecuencia, la amplitud y el ruido de fondo de cada alarma, y None en cada ventilador. np.r_ une la desviación estándar y el resumen de un clip en una fila de tres números.

Python 3 · ejemplo reproducible
X = np.array([np.r_[x.std(), resumen(x)] for x in ondas])

print(X.shape, y.mean())
Salida esperada

La salida esperada está oculta.

train_test_split recibe también indices, las posiciones de 0 a 199, para saber después qué clips quedaron en prueba. stratify=y deja la misma proporción de alarmas en las dos partes.

Python 3 · ejemplo reproducible
from sklearn.model_selection import train_test_split

indices = np.arange(len(y))
X_ent, X_prueba, y_ent, y_prueba, i_ent, i_prueba = train_test_split(
    X, y, indices, test_size=0.3, stratify=y, random_state=0
)
print(len(y_ent), len(y_prueba))
Salida esperada

La salida esperada está oculta.

make_pipeline(StandardScaler(), LogisticRegression()) estandariza la característica antes de la regresión logística. X_ent[:, [columna]], con la columna entre corchetes, conserva la forma de matriz (140, 1) que espera fit.

Pregunta. Ordena las tres características por la exactitud de prueba que esperas. ¿Alguna queda cerca de 0.5? El centroide del ruido está cerca de 2000 Hz: ¿qué alarmas se confunden con ruido si solo usas el centroide?

Python 3 · ejemplo reproducible
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

for columna, nombre in enumerate(["desviación", "centroide", "fracción de pico"]):
    modelo = make_pipeline(StandardScaler(), LogisticRegression())
    modelo.fit(X_ent[:, [columna]], y_ent)
    print(nombre, round(modelo.score(X_prueba[:, [columna]], y_prueba), 3))
Salida esperada

La salida esperada está oculta.

La máscara predict(...) != y_prueba marca los clips de prueba mal clasificados, e i_prueba traduce cada uno a su posición en X, y y detalles. Cada error se imprime con su índice, su etiqueta, su centroide, su fracción de pico y sus detalles. La última línea compara la fracción de pico mínima de las alarmas de entrenamiento con la máxima de los ventiladores.

Pregunta. Con la fracción de pico queda un error en 60 clips de prueba. ¿Es una alarma o un ventilador? ¿Qué amplitud y qué ruido de fondo esperas en ese clip?

Python 3 · ejemplo reproducible
modelo = make_pipeline(StandardScaler(), LogisticRegression())
modelo.fit(X_ent[:, [2]], y_ent)
errores = i_prueba[modelo.predict(X_prueba[:, [2]]) != y_prueba]

for i in errores:
    print(i, y[i], round(X[i, 1], 1), round(X[i, 2], 3), detalles[i])
print(round(X_ent[y_ent == 1, 2].min(), 3), round(X_ent[y_ent == 0, 2].max(), 3))
Salida esperada

La salida esperada está oculta.

Modelos preentrenados de audio

Los modelos preentrenados de audio aprenden sus propias características a partir de miles de horas de grabaciones. Cada uno fija la frecuencia de muestreo que acepta. HuBERT, Whisper, Qwen-Audio y Voxtral esperan audio mono a 16 000 Hz. Los clips de esta lección tienen 8000 Hz, así que primero se remuestrean. resample_poly con up=2, down=1 lleva la señal de 8000 a 16 000 Hz y calcula los valores nuevos con un filtro. frecuencia_dominante busca el máximo del espectro np.fft.rfft de todo el clip, con la fs que se le declara.

Pregunta. ¿Cuántas muestras tiene el clip A después de remuestrear? Si pasas el clip de 8000 Hz a un modelo que supone 16 000 Hz, ¿en qué frecuencia oye el tono de 1000 Hz?

Python 3 · ejemplo reproducible
from scipy.signal import resample_poly

clip_16k = resample_poly(clips["A"], up=2, down=1)


def frecuencia_dominante(x, fs_declarada):
    espectro = np.abs(np.fft.rfft(x))
    return np.fft.rfftfreq(len(x), d=1 / fs_declarada)[espectro.argmax()]


print(len(clips["A"]), len(clip_16k))
print(frecuencia_dominante(clip_16k, 16000), frecuencia_dominante(clips["A"], 16000))
Salida esperada

La salida esperada está oculta.

HuBERT recibe la onda directamente. Su primera etapa es una pila de siete convoluciones de una dimensión con núcleos [10, 3, 3, 3, 3, 2, 2] y pasos [5, 2, 2, 2, 2, 2, 2]. Cada convolución sin relleno produce (n − núcleo) // paso + 1 posiciones. Aplicar esa cuenta siete veces, empezando por el número de muestras, da el número de vectores de salida.

Whisper corta el audio en ventanas de 30 s y rellena con ceros las más cortas. Calcula un espectrograma con ventanas de 400 muestras (25 ms) y un salto de 160 muestras (10 ms). A diferencia de la cuenta con center=False, Whisper rellena los bordes de la señal y descarta la última ventana, así que su espectrograma tiene n // 160 ventanas.

Con NumPy, np.pad(x, (0, 30 * 16000 - len(x))) agrega ceros al final de un clip x hasta completar 30 s; la tupla da cuántos ceros van antes y después.

Pregunta. ¿Cuántos vectores produce HuBERT para el medio segundo de clip_16k y para un segundo completo? ¿Cuántas muestras recibe Whisper y cuántas ventanas tiene su espectrograma?

Ver resultado

Cada fila es un clip que entra a un modelo. La columna «muestras a 16 000 Hz» da la longitud de la onda que recibe el modelo y la columna «salida», cuántos vectores produce HuBERT o cuántas ventanas tiene el espectrograma de Whisper. Por ejemplo, la fila «HuBERT, 1 s» dice que 16000 muestras dan 49 vectores.

entradamuestras a 16 000 Hzsalida
HuBERT, clip_16k (0.5 s)800024 vectores
HuBERT, 1 s1600049 vectores
Whisper, clip_16k rellenado a 30 s4800003000 ventanas
Lectura. HuBERT produce 24 vectores para medio segundo y 49 para un segundo: cerca de uno cada 20 ms. Whisper recibe siempre 480 000 muestras y su espectrograma tiene 3000 ventanas.

Cada modelo usa esas entradas de otra forma:

  • HuBERT (Meta) se entrenó sin transcripciones. Oculta algunos cuadros de su secuencia interna y aprende a predecir a qué grupo pertenece cada cuadro oculto; los grupos salen de aplicar k-means a características del audio. Devuelve una secuencia de vectores, uno cada 20 ms: 768 números por vector en la versión Base y 1024 en Large. No devuelve texto. Para clasificar un clip, esos vectores se promedian en el tiempo, igual que las características por ventana de esta lección, y una capa lineal decide la clase.
  • Whisper (OpenAI) es un transformer encoder-decoder. Su entrada no es la onda: es un espectrograma log-Mel de 80 bandas por 3000 ventanas (128 bandas en large-v3). Las bandas Mel son más angostas en frecuencias graves, como la audición humana, y el logaritmo comprime las diferencias de potencia. El encoder reduce las 3000 ventanas a 1500 posiciones. El decoder devuelve tokens de texto: la transcripción, su traducción al inglés, el idioma detectado y marcas de tiempo.
  • Qwen-Audio (Alibaba) une un encoder de audio que parte del de Whisper con el modelo de lenguaje Qwen-7B. Recibe audio y una instrucción en texto, y devuelve texto. Las preguntas pueden tratar de voz, sonidos del ambiente o música: «¿qué suena en este clip?» es una entrada válida.
  • Voxtral (Mistral AI) sigue el mismo esquema: un encoder de audio derivado de Whisper y un modelo de lenguaje. Se publicó en dos tamaños, Voxtral Mini y Voxtral Small, con pesos abiertos bajo licencia Apache 2.0. Recibe audio y texto, y devuelve texto: transcripciones, resúmenes y respuestas sobre lo que se dice en la grabación.

Práctica

Traza guiada

Para el siguiente clip, escribe antes de procesar: su forma, cuántas muestras dura un periodo del tono, la forma del espectrograma con n_fft = 128, salto = 64 y center=False, la separación entre frecuencias y la frecuencia del máximo en cada ventana.

Python 3 · ejemplo reproducible
fs_traza = 4000
t_traza = np.arange(int(fs_traza * 0.25)) / fs_traza
x_traza = 0.4 * np.sin(2 * np.pi * 500 * t_traza)

Comprueba con x_traza.shape, torch.stft y np.fft.rfftfreq(128, d=1 / fs_traza). Si una predicción falla, revisa la fórmula del número de ventanas y que usaste fs_traza y no fs.

Tamaño de ventana

Cambia n_fft a 64 y después a 1024, con salto = n_fft // 2, y vuelve a definir ventana y frecuencias con el valor nuevo. Antes de procesar, predice para cada valor la separación entre frecuencias, el número de ventanas de un clip de 0.5 s y si la fracción de pico de A sube o baja. Después, reconstruye X con el nuevo resumen y repite las tres exactitudes. Relaciona el cambio de la fracción de pico con el número de frecuencias entre las que se reparte la potencia del ruido.

Pitido breve

Construye 200 clips de 0.8 s de ruido blanco. En la mitad, suma un tono de 0.1 s en una posición al azar del clip. Antes de procesar, predice qué pasa con la fracción de pico promediada en todas las ventanas. Compara dos resúmenes de la fracción de pico: el promedio y el máximo sobre las ventanas. Verifica con assert que todos los vectores de características tienen la misma forma y reporta la exactitud de prueba de cada resumen.

Reto opcional

Genera un barrido: un tono cuya frecuencia sube de 300 a 3000 Hz a lo largo de 1 s. La fase de un barrido lineal es 2π · (f0 · t + (f1 − f0) · t² / (2 · duración)). Grafica la frecuencia del máximo en cada ventana contra el tiempo. Diseña una característica por ventana que distinga un barrido de un tono fijo y comprueba si el modelo de la fracción de pico los separa.

Errores que revisar

  • Si x.shape tiene dos ejes, como (4000, 2), el clip es estéreo. Promedia los canales con x.mean(axis=1) antes del espectrograma.
  • Si el máximo del espectrograma aparece en una frecuencia que no generaste, compara la frecuencia con fs / 2. Por encima de ese límite, el muestreo la refleja: 7000 Hz con fs = 8000 aparece en 1000 Hz.
  • Si la frecuencia dominante sale al doble o a la mitad de la esperada, revisa que la fs declarada sea la del archivo. Un modelo de 16 000 Hz necesita el clip remuestreado.
  • Si np.array(filas) lanza ValueError: setting an array element with a sequence, guardaste valores por ventana de clips con duraciones distintas. Resume cada característica sobre el eje de ventanas antes de apilar los clips.
  • Si torch.stft lanza RuntimeError: stft requires the return_complex parameter be given for real inputs, agrega return_complex=True y calcula la potencia con Z.abs() ** 2.

Cierre

Explica por qué el espectrograma distingue la alarma C del ventilador D, que tienen casi la misma desviación estándar, y qué pasos siguen tus clips de 8000 Hz antes de entrar a Whisper.

Lista de comprobación

  • ¿Qué forma tiene un clip mono de d segundos con frecuencia de muestreo fs, y cuál es la frecuencia más alta que representa?
  • ¿Qué recorre cada eje de la potencia P y por qué su número de ventanas cambia con la duración?
  • ¿Por qué la fracción de pico separa alarmas de ruido cuando un umbral sobre el centroide confunde con ruido a todas las alarmas agudas?
  • ¿Qué recibe y qué devuelve HuBERT, y en qué se diferencia de lo que devuelve Whisper?

Documentación oficial

Cargando progreso…