Audio: del sonido a los números
Los 18 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; en la ficha Teoría está la explicación de cada uno.
Leer un audio
import re
import tarfile
import textwrap
import urllib.request
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import soundfile as sf
import torch
import torchaudio
from torch import nn
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
DATOS = Path("datos")
def bajar(url, destino):
"""Baja un archivo de internet, solo si todavía no está."""
destino = Path(destino)
if not destino.exists():
destino.parent.mkdir(parents=True, exist_ok=True)
pedido = urllib.request.Request(
url, headers={"User-Agent": "ruta-obia/1.0 (curso de IA)"})
with urllib.request.urlopen(pedido) as respuesta:
destino.write_bytes(respuesta.read())
return destino
# Don Quijote, cap. I, leído por Nessi para LibriVox (dominio público)
wav = DATOS / "audio-ruta/quijote-cap1-55s-16k.wav"
if not wav.exists():
url = ("https://archive.org/download/don_quijote_vol1_0706_librivox/"
"quijote_vol1_03_cervantes_64kb.mp3")
mp3 = bajar(url, DATOS / "audio-ruta/quijote-vol1-03-librivox.mp3")
# Del segundo 36,0 al 90,8: antes viene el aviso de LibriVox
trozo, sr_mp3 = sf.read(mp3, dtype="float32",
start=int(36.0 * 22050),
stop=int(90.8 * 22050))
trozo = torchaudio.functional.resample(torch.from_numpy(trozo),
sr_mp3, 16000)
sf.write(wav, trozo.numpy(), 16000)
onda, sr = sf.read(wav, dtype="float32")
print("Forma:", onda.shape, " tipo:", onda.dtype)
print("Frecuencia de muestreo:", sr, "muestras por segundo")
print(f"Duración: {len(onda) / sr:.1f} s")
print("Seis muestras seguidas:", np.round(onda[16000:16006], 3))t = np.arange(len(onda)) / sr # el tiempo de cada muestra
tres = t < 3
zoom = (t >= 1.30) & (t < 1.32) # 20 milisegundos
fig, (arriba, abajo) = plt.subplots(2, 1, figsize=(4.8, 4.8))
arriba.plot(t[tres], onda[tres], color="C0", linewidth=0.5)
arriba.axvspan(1.30, 1.32, color="C1")
arriba.set_xlabel("Tiempo (s)")
arriba.set_ylabel("Amplitud")
arriba.set_title("Los primeros 3 segundos")
abajo.plot(t[zoom] * 1000, onda[zoom], color="C2", linewidth=1.2)
abajo.scatter(t[zoom] * 1000, onda[zoom], s=12, color="C0",
edgecolors="none")
abajo.set_xlabel("Tiempo (ms)")
abajo.set_ylabel("Amplitud")
abajo.set_title("Zoom: 20 ms, una muestra por punto")
plt.show()f_tono = 900
t_fino = np.linspace(0, 0.01, 2000) # «continuo»: 10 ms
t_lento = np.arange(0, 0.01, 1 / 1000) # 1000 muestras por segundo
plt.plot(t_fino * 1000, np.cos(2 * np.pi * f_tono * t_fino),
color="C2", linewidth=1, label="Tono real de 900 Hz")
plt.plot(t_fino * 1000, np.cos(2 * np.pi * 100 * t_fino),
color="C0", linestyle="--", label="Lo que parece: 100 Hz")
plt.scatter(t_lento * 1000, np.cos(2 * np.pi * f_tono * t_lento),
color="C1", s=60, zorder=3, label="Muestras a 1000 Hz")
plt.xlabel("Tiempo (ms)")
plt.ylabel("Amplitud")
plt.title("Muestrear muy lento engaña")
plt.legend(loc="upper center", bbox_to_anchor=(0.5, -0.3), fontsize=13)
plt.show()
# ¿Las muestras de 900 Hz y de 100 Hz son las mismas?
n = np.arange(1000)
print(np.allclose(np.cos(2 * np.pi * 900 * n / 1000),
np.cos(2 * np.pi * 100 * n / 1000)))De la onda a las frecuencias
sr_m = 8000
t1 = np.arange(sr_m) / sr_m # un segundo
rng = np.random.default_rng(0)
mezcla = (1.0 * np.sin(2 * np.pi * 220 * t1)
+ 0.5 * np.sin(2 * np.pi * 440 * t1)
+ 0.25 * np.sin(2 * np.pi * 660 * t1)
+ 0.05 * rng.normal(size=sr_m))
espectro = np.fft.rfft(mezcla) # números complejos
frecuencias = np.fft.rfftfreq(len(mezcla), d=1 / sr_m)
amplitud = 2 * np.abs(espectro) / len(mezcla)
print("Muestras:", len(mezcla), " frecuencias:", len(espectro))
print(f"De {frecuencias[0]:.0f} a {frecuencias[-1]:.0f} Hz, "
f"cada {frecuencias[1]:.0f} Hz")
for i in np.argsort(amplitud)[::-1][:3]:
print(f"{frecuencias[i]:5.0f} Hz: amplitud {amplitud[i]:.2f}")
plt.plot(frecuencias, amplitud, color="C0", linewidth=1.5)
plt.scatter([220], [amplitud[220]], color="C1", s=60, zorder=3)
plt.xlim(0, 1000)
plt.xlabel("Frecuencia (Hz)")
plt.ylabel("Amplitud")
plt.title("La FFT encuentra los tres tonos")
plt.show()El espectrograma: Fourier por pedacitos
def stft(senal, n_fft, salto):
"""Magnitud de la STFT: una columna por ventana."""
hann = np.hanning(n_fft)
inicios = range(0, len(senal) - n_fft + 1, salto)
cuadros = np.array([senal[i:i + n_fft] * hann for i in inicios])
return np.abs(np.fft.rfft(cuadros, axis=1)).T # (frec., tiempo)
def en_db(magnitud):
"""Decibeles: 20·log10 de la amplitud, con un piso para no tomar
log de 0."""
return 20 * np.log10(np.maximum(magnitud, 1e-4))from matplotlib.colors import LinearSegmentedColormap
tinta = LinearSegmentedColormap.from_list("tinta", ["white", "C0"])
senal = np.sin(2 * np.pi * 500 * t1) + np.sin(2 * np.pi * 560 * t1)
for inicio in [0.25, 0.5, 0.75]:
i = int(inicio * sr_m)
senal[i:i + 8] += 6 # un chasquido de 1 ms
fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
ejes[0, 0].plot(t1, senal, color="C0", linewidth=0.4)
ejes[0, 0].set_title("La señal")
ejes[0, 0].set_xlabel("Tiempo (s)")
for eje, n_fft in zip(ejes.flat[1:], [64, 256, 2048]):
S = en_db(stft(senal, n_fft, n_fft // 4))
eje.imshow(S, origin="lower", aspect="auto", cmap=tinta,
vmin=S.max() - 40, extent=[0, 1, 0, sr_m / 2])
eje.set_ylim(0, 1500)
eje.set_yticks([500, 1000, 1500])
eje.set_title(f"n_fft = {n_fft}")
eje.set_xlabel("Tiempo (s)")
eje.grid(False)
ejes[0, 0].set_yticks([])
ejes[1, 1].set_yticklabels([])
ejes[1, 0].set_ylabel("Frecuencia (Hz)")
plt.show()
for n_fft in [64, 256, 2048]:
print(f"n_fft = {n_fft:4}: ventana de {1000 * n_fft / sr_m:5.1f} ms,"
f" una fila cada {sr_m / n_fft:6.2f} Hz")for f in [250, 500, 1000, 2000, 4000, 8000]:
print(f"{f:5} Hz -> {2595 * np.log10(1 + f / 700):6.0f} mels")
a_mel = torchaudio.transforms.MelSpectrogram(
sample_rate=sr, n_fft=400, hop_length=160, n_mels=64)
a_db = torchaudio.transforms.AmplitudeToDB()
voz = onda[:3 * sr] # los primeros 3 segundos
S_lineal = en_db(stft(voz, 400, 160))
S_mel = a_db(a_mel(torch.from_numpy(voz))).numpy()
print("STFT:", S_lineal.shape, " Mel:", S_mel.shape)
fig, (arriba, abajo) = plt.subplots(2, 1, figsize=(4.8, 4.8))
arriba.imshow(S_lineal, origin="lower", aspect="auto", cmap=tinta,
vmin=S_lineal.max() - 70, extent=[0, 3, 0, sr / 2])
arriba.set_title("Espectrograma (Hz)")
arriba.set_ylabel("Frecuencia (Hz)")
abajo.imshow(S_mel, origin="lower", aspect="auto", cmap=tinta,
vmin=S_mel.max() - 70, extent=[0, 3, 0, 64])
abajo.set_title("Espectrograma de Mel (64 bandas)")
abajo.set_ylabel("Banda de Mel")
abajo.set_xlabel("Tiempo (s)")
for eje in (arriba, abajo):
eje.grid(False)
plt.show()Dígitos hablados: los datos
# Free Spoken Digit Dataset, de Zohar Jackson y colaboradores,
# con licencia CC BY-SA 4.0 (github.com/Jakobovski/free-spoken-digit-dataset)
carpeta = DATOS / "fsdd/recordings"
if not carpeta.exists():
url = ("https://github.com/Jakobovski/free-spoken-digit-dataset/"
"archive/refs/tags/v1.0.10.tar.gz")
comprimido = bajar(url, DATOS / "fsdd/fsdd-v1.0.10.tar.gz")
with tarfile.open(comprimido) as tar:
for miembro in tar.getmembers():
if "/recordings/" in miembro.name:
miembro.name = "recordings/" + Path(miembro.name).name
tar.extract(miembro, DATOS / "fsdd", filter="data")
audios, digitos, hablantes, tomas = [], [], [], []
for archivo in sorted(carpeta.glob("*.wav")):
# El nombre dice todo: «7_jackson_32.wav»
digito, hablante, toma = archivo.stem.split("_")
x, sr_d = sf.read(archivo, dtype="float32")
audios.append(x)
digitos.append(int(digito))
hablantes.append(hablante)
tomas.append(int(toma))
digitos, tomas = np.array(digitos), np.array(tomas)
duraciones = np.array([len(x) for x in audios]) / sr_d
print("Audios:", len(audios), " muestreo:", sr_d, "Hz")
print("Hablantes:", sorted(set(hablantes)))
print(f"Duración: mínima {duraciones.min():.2f} s, "
f"mediana {np.median(duraciones):.2f} s, "
f"máxima {duraciones.max():.2f} s")
print("Más largos que 1 s:", (duraciones > 1).sum())LARGO = sr_d # 1 segundo = 8000 muestras
def a_un_segundo(x):
"""Recorta o rellena con silencio hasta LARGO muestras."""
if len(x) >= LARGO:
return x[:LARGO]
return np.pad(x, (0, LARGO - len(x)))
ondas = torch.from_numpy(np.stack([a_un_segundo(x) for x in audios]))
mel_d = torchaudio.transforms.MelSpectrogram(
sample_rate=sr_d, n_fft=256, hop_length=80, n_mels=40)
log_mel = torch.log(mel_d(ondas) + 1e-6) # (audios, bandas, cuadros)
print("Ondas:", tuple(ondas.shape), " log-Mel:", tuple(log_mel.shape))
# Los diez dígitos dichos por la misma persona
fig, ejes = plt.subplots(5, 2, figsize=(4.8, 4.8))
for d, eje in enumerate(ejes.T.flat):
k = np.flatnonzero((digitos == d) & (np.array(hablantes) == "jackson")
& (tomas == 0))[0]
eje.imshow(log_mel[k], origin="lower", aspect="auto", cmap=tinta)
eje.text(0.97, 0.9, str(d), transform=eje.transAxes, ha="right",
va="top", fontsize=15, fontweight="bold", color="C0")
eje.set_xticks([])
eje.set_yticks([])
fig.suptitle("Log-Mel de los dígitos 0 a 9")
plt.show()Una CNN que escucha
from torch.utils.data import DataLoader, TensorDataset
es_prueba = torch.from_numpy(tomas < 5)
es_val = torch.from_numpy((tomas >= 5) & (tomas < 10))
es_ent = torch.from_numpy(tomas >= 10)
X = log_mel.unsqueeze(1) # un canal: (N, 1, 40, 101)
y = torch.from_numpy(digitos)
media, desvio = X[es_ent].mean(), X[es_ent].std()
X = (X - media) / desvio
cargador_ent = DataLoader(
TensorDataset(X[es_ent], y[es_ent]), batch_size=64,
shuffle=True, generator=torch.Generator().manual_seed(0))
cargador_val = DataLoader(TensorDataset(X[es_val], y[es_val]),
batch_size=300)
cargador_pru = DataLoader(TensorDataset(X[es_prueba], y[es_prueba]),
batch_size=300)
print("Entrenamiento:", len(cargador_ent.dataset),
" validación:", len(cargador_val.dataset),
" prueba:", len(cargador_pru.dataset))def bloque(entran, salen):
"""Convolución 3×3 + BatchNorm + ReLU."""
return nn.Sequential(nn.Conv2d(entran, salen, 3, padding=1),
nn.BatchNorm2d(salen), nn.ReLU())
torch.manual_seed(0)
cnn = nn.Sequential(
bloque(1, 16), nn.MaxPool2d(2), # (16, 20, 50)
bloque(16, 32), nn.MaxPool2d(2), # (32, 10, 25)
bloque(32, 32), # (32, 10, 25)
nn.AdaptiveAvgPool2d((5, 6)), # (32, 5, 6)
nn.Flatten(), # 960
nn.Linear(32 * 5 * 6, 10),
)
print("Parámetros:", sum(p.numel() for p in cnn.parameters()))def evaluar(modelo, cargador, funcion_perdida, dispositivo="cpu"):
"""Pérdida media y exactitud de `modelo` sobre `cargador`."""
modelo.eval()
suma, aciertos, total = 0.0, 0, 0
with torch.no_grad():
for x, y in cargador:
x, y = x.to(dispositivo), y.to(dispositivo)
salida = modelo(x)
suma += funcion_perdida(salida, y).item() * len(y)
aciertos += (salida.argmax(dim=1) == y).sum().item()
total += len(y)
return suma / total, aciertos / total
def entrenar(modelo, cargador_ent, cargador_val, epocas, optimizador,
funcion_perdida=None, planificador=None, dispositivo="cpu",
mostrar=True):
"""Entrena `modelo` y devuelve el historial de cada época."""
if funcion_perdida is None:
funcion_perdida = nn.CrossEntropyLoss()
modelo.to(dispositivo)
historial = {"perdida_ent": [], "exactitud_ent": [],
"perdida_val": [], "exactitud_val": []}
for epoca in range(1, epocas + 1):
modelo.train()
suma, aciertos, total = 0.0, 0, 0
for x, y in cargador_ent:
x, y = x.to(dispositivo), y.to(dispositivo)
optimizador.zero_grad()
salida = modelo(x)
perdida = funcion_perdida(salida, y)
perdida.backward()
optimizador.step()
suma += perdida.item() * len(y)
aciertos += (salida.argmax(dim=1) == y).sum().item()
total += len(y)
if planificador is not None:
planificador.step()
perdida_val, exactitud_val = evaluar(
modelo, cargador_val, funcion_perdida, dispositivo)
historial["perdida_ent"].append(suma / total)
historial["exactitud_ent"].append(aciertos / total)
historial["perdida_val"].append(perdida_val)
historial["exactitud_val"].append(exactitud_val)
if mostrar:
print(f"Época {epoca}: pérdida {suma / total:.3f}, "
f"validación {perdida_val:.3f}, "
f"exactitud de validación {exactitud_val:.3f}")
return historialtorch.manual_seed(0)
hist = entrenar(cnn, cargador_ent, cargador_val, epocas=8,
optimizador=torch.optim.Adam(cnn.parameters(), lr=1e-3),
dispositivo=device)
# La prueba, una sola vez, con la red ya entrenada
_, exactitud_prueba = evaluar(cnn, cargador_pru, nn.CrossEntropyLoss(),
device)
print(f"Exactitud de prueba: {exactitud_prueba:.3f}")Embeddings de audio con HuBERT
from transformers import AutoFeatureExtractor, AutoModel
extractor = AutoFeatureExtractor.from_pretrained(
"facebook/hubert-base-ls960")
hubert = AutoModel.from_pretrained("facebook/hubert-base-ls960")
hubert = hubert.to(device).eval()
indices_prueba = np.flatnonzero(tomas < 5)
embeddings = []
with torch.no_grad():
for k in indices_prueba:
x16 = torchaudio.functional.resample(
torch.from_numpy(audios[k]), sr_d, 16000)
entrada = extractor(x16.numpy(), sampling_rate=16000,
return_tensors="pt").to(device)
cuadros = hubert(**entrada).last_hidden_state # (1, T, 768)
embeddings.append(cuadros.mean(dim=1)[0].cpu())
E = torch.stack(embeddings).numpy()
y_h = digitos[indices_prueba]
print(f"Último audio: {len(x16) / 16000:.2f} s ->",
tuple(cuadros.shape))
print("Embeddings:", E.shape)import umap
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
plano = umap.UMAP(n_neighbors=15, min_dist=0.1, metric="cosine",
random_state=0, n_jobs=1).fit_transform(E)
plt.scatter(plano[:, 0], plano[:, 1], s=0) # solo fija los límites
for (a, b), d in zip(plano, y_h):
plt.text(a, b, str(d), ha="center", va="center", fontsize=10,
color="C1" if d == 7 else "C0", alpha=0.8)
plt.title("HuBERT: 300 audios, cada uno con su dígito")
plt.xticks([])
plt.yticks([])
plt.show()
# ¿Alcanza el embedding para clasificar? Validación cruzada, 5 pliegues
clasificador = LogisticRegression(max_iter=2000)
exactitudes = cross_val_score(clasificador, E, y_h, cv=5)
print("Exactitud por pliegue:", np.round(exactitudes, 2))
print(f"Promedio: {exactitudes.mean():.3f}")Transcribir con Whisper
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
whisper = {}
for nombre in ["tiny", "base"]:
ruta = f"openai/whisper-{nombre}"
procesador = AutoProcessor.from_pretrained(ruta)
modelo = AutoModelForSpeechSeq2Seq.from_pretrained(ruta)
whisper[nombre] = (procesador, modelo.to(device).eval())
millones = sum(p.numel() for p in modelo.parameters()) / 1e6
print(f"whisper-{nombre}: {millones:.0f} millones de parámetros")
@torch.no_grad()
def transcribir(audio, nombre, idioma):
"""Texto de un audio a 16 000 Hz, con el Whisper `nombre`."""
procesador, modelo = whisper[nombre]
largo = len(audio) > 30 * 16000
if largo:
# Más de 30 s: Whisper lo procesa en tramos de 30 s
x = procesador(audio, sampling_rate=16000, return_tensors="pt",
truncation=False, padding="longest",
return_attention_mask=True)
else:
x = procesador(audio, sampling_rate=16000, return_tensors="pt")
ids = modelo.generate(**x.to(device), language=idioma,
task="transcribe", return_timestamps=largo)
return procesador.batch_decode(ids, skip_special_tokens=True)[0]
def mostrar(texto):
"""Imprime el texto en renglones de 72 caracteres."""
print(textwrap.fill(texto.strip(), 72))
texto_tiny = transcribir(onda, "tiny", "es")
mostrar(texto_tiny)import jiwer
REFERENCIA = (
"En un lugar de la Mancha, de cuyo nombre no quiero acordarme, no ha "
"mucho tiempo que vivía un hidalgo de los de lanza en astillero, "
"adarga antigua, rocín flaco y galgo corredor. Una olla de algo más "
"vaca que carnero, salpicón las más noches, duelos y quebrantos los "
"sábados, lantejas los viernes, algún palomino de añadidura los "
"domingos, consumían las tres partes de su hacienda. El resto della "
"concluían sayo de velarte, calzas de velludo para las fiestas, con "
"sus pantuflos de lo mesmo, y los días de entresemana se honraba con "
"su vellorí de lo más fino. Tenía en su casa una ama que pasaba de "
"los cuarenta, y una sobrina que no llegaba a los veinte, y un mozo "
"de campo y plaza, que así ensillaba el rocín como tomaba la "
"podadera.")
def normalizar(texto):
"""Minúsculas, sin puntuación y con un espacio entre palabras."""
texto = re.sub(r"[^\w\s]", " ", texto.lower())
return " ".join(texto.split())
ref = normalizar(REFERENCIA)
hip = normalizar(texto_tiny)
cuentas = jiwer.process_words(ref, hip)
print("Palabras de la referencia:", len(ref.split()))
print("Sustituidas:", cuentas.substitutions,
" borradas:", cuentas.deletions,
" insertadas:", cuentas.insertions)
print(f"WER: {cuentas.wer:.3f}")import io
import pandas as pd
from huggingface_hub import hf_hub_download
def con_ruido(audio, snr_db, semilla=0):
"""Suma ruido gaussiano con la relación señal-ruido pedida."""
rng = np.random.default_rng(semilla)
potencia_ruido = np.mean(audio ** 2) / 10 ** (snr_db / 10)
ruido = rng.normal(0, np.sqrt(potencia_ruido), len(audio))
return (audio + ruido).astype(np.float32)
# 10 frases de LibriSpeech (Panayotov et al., 2015), licencia CC BY 4.0
parquet = hf_hub_download("hf-internal-testing/librispeech_asr_dummy",
"clean/validation-00000-of-00001.parquet",
repo_type="dataset")
frases = pd.read_parquet(parquet).head(10)
ingles = [sf.read(io.BytesIO(a["bytes"]), dtype="float32")[0]
for a in frases["audio"]]
refs_en = [normalizar(texto) for texto in frases["text"]]
print("Frases en inglés:", len(ingles), " duración total:",
f"{sum(len(x) for x in ingles) / 16000:.0f} s")
mostrar("Ejemplo: " + refs_en[0])
textos, wer = {}, {}
for nombre in ["tiny", "base"]:
textos[nombre, "es"] = transcribir(onda, nombre, "es")
textos[nombre, "es + ruido"] = transcribir(con_ruido(onda, 10),
nombre, "es")
textos[nombre, "en"] = [transcribir(x, nombre, "en")
for x in ingles]
textos[nombre, "en + ruido"] = [
transcribir(con_ruido(x, 10, semilla=i), nombre, "en")
for i, x in enumerate(ingles)]
for caso in ["es", "es + ruido"]:
wer[nombre, caso] = jiwer.wer(ref, normalizar(textos[nombre, caso]))
for caso in ["en", "en + ruido"]:
hips = [normalizar(t) for t in textos[nombre, caso]]
wer[nombre, caso] = jiwer.wer(refs_en, hips)
casos = ["es", "es + ruido", "en", "en + ruido"]
print("modelo " + "".join(f"{c:>12}" for c in casos))
for nombre in ["tiny", "base"]:
print(f"{nombre:6} " + "".join(f"{wer[nombre, c]:12.3f}"
for c in casos))
posiciones = np.arange(len(casos))
for desplazar, nombre, color in [(-0.2, "tiny", "C2"),
(0.2, "base", "C0")]:
plt.bar(posiciones + desplazar, [wer[nombre, c] for c in casos],
width=0.4, color=color, label=f"whisper-{nombre}")
plt.xticks(posiciones, ["Español", "Español\ncon ruido", "Inglés",
"Inglés\ncon ruido"], fontsize=13)
plt.ylabel("WER (menos es mejor)")
plt.title("Whisper con y sin ruido (SNR 10 dB)")
plt.legend()
plt.show()