Módulo 8 · Lenguaje y audio
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:
| clip | contenido | muestras | media | desviación estándar |
|---|---|---|---|---|
| A | alarma | 4000 | -0.001 | 0.357 |
| B | ventilador | 4000 | 0.006 | 0.362 |
| C | alarma | 4000 | 0.001 | 0.288 |
| D | ventilador | 4000 | 0.001 | 0.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?
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)
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.
| tono | primeros 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.
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ódigo
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.
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.
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?
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)))
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?
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))
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,).
(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ódigo
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.
X = np.array([np.r_[x.std(), resumen(x)] for x in ondas])
print(X.shape, y.mean())
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.
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))
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?
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))
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?
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))
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?
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))
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.
| entrada | muestras a 16 000 Hz | salida |
|---|---|---|
HuBERT, clip_16k (0.5 s) | 8000 | 24 vectores |
| HuBERT, 1 s | 16000 | 49 vectores |
Whisper, clip_16k rellenado a 30 s | 480000 | 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.
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.shapetiene dos ejes, como(4000, 2), el clip es estéreo. Promedia los canales conx.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 confs = 8000aparece en 1000 Hz. - Si la frecuencia dominante sale al doble o a la mitad de la esperada, revisa que la
fsdeclarada sea la del archivo. Un modelo de 16 000 Hz necesita el clip remuestreado. - Si
np.array(filas)lanzaValueError: 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.stftlanzaRuntimeError: stft requires the return_complex parameter be given for real inputs, agregareturn_complex=Truey calcula la potencia conZ.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
dsegundos con frecuencia de muestreofs, y cuál es la frecuencia más alta que representa? - ¿Qué recorre cada eje de la potencia
Py 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
- torch.stft: parámetros
n_fft,hop_length,windowycenter, y forma de la salida. - HuBERT en Hugging Face Transformers: entrada de onda a 16 kHz y salida de estados ocultos por cuadro.
- Whisper en Hugging Face Transformers: extractor de características log-Mel, generación de transcripciones y tareas del decoder.
- Qwen-Audio: repositorio oficial con la arquitectura, las tareas de audio y el formato de instrucciones.
- Voxtral en Hugging Face Transformers: entrada de audio y texto, transcripción y preguntas sobre el audio.