Todo sonido es una suma de ondas sinusoidales

En 1807, Joseph Fourier hizo una afirmación tan audaz que los matemáticos más destacados de su época se negaron a creerla: cualquier señal periódica puede descomponerse en una suma de ondas sinusoidales de diferentes frecuencias, amplitudes y fases. Un violín tocando el La central no es solo una vibración a 440 Hz — es un tono fundamental a 440 Hz más armónicos a 880 Hz, 1320 Hz y más allá, cada uno con su propia amplitud. Elimina los armónicos y obtienes una onda sinusoidal estéril; añádelos de nuevo y la riqueza del instrumento regresa. Esta idea es la razón por la que el análisis de Fourier importa para el audio: el habla y la música son sumas complejas de frecuencias, y entender esas frecuencias es el primer paso para entender el sonido.

Hagamos esto concreto. Construiremos una forma de onda de aspecto complejo a partir de solo tres ondas sinusoidales. Una onda sinusoidal se define por tres cantidades: su frecuencia (cuántos ciclos por segundo, en Hz), su amplitud (qué tan alta es la onda), y su fase (dónde en su ciclo comienza). La fórmula general es:

$$x(t) = A \sin(2\pi f t + \phi)$$

donde $A$ es la amplitud, $f$ es la frecuencia en Hz, $t$ es el tiempo en segundos y $\phi$ es el desfase en radianes. Cuando $t = 0$, la onda comienza en $A \sin(\phi)$. Cuando $t = 1/f$, un ciclo completo ha transcurrido y el argumento ha aumentado en $2\pi$. Duplicar $f$ comprime el doble de ciclos en el mismo intervalo de tiempo.

El código a continuación crea tres ondas sinusoidales individuales — a 3 Hz, 7 Hz y 13 Hz — cada una con una amplitud diferente. Luego las suma muestra por muestra para producir una señal compuesta. Observa cómo la onda compuesta no se parece en nada a una onda sinusoidal simple, pero está construida enteramente a partir de tres componentes predecibles.

import math, json, js

# Parameters for three sine waves
freqs  = [3, 7, 13]        # Hz
amps   = [1.0, 0.6, 0.3]   # amplitudes
phases = [0, 0, 0]          # no phase offset for clarity

# Sample 1 second at 200 samples/sec (enough to see all waves clearly)
sr = 200
N = sr  # 200 samples = 1 second
t_vals = [i / sr for i in range(N)]

# Generate each sine wave and the composite
waves = []
for f, a, p in zip(freqs, amps, phases):
    wave = [a * math.sin(2 * math.pi * f * t + p) for t in t_vals]
    waves.append(wave)

composite = [sum(w[i] for w in waves) for i in range(N)]

# Build plot: 4 lines (3 individual + composite)
colors = ["#94a3b8", "#94a3b8", "#94a3b8", "#3b82f6"]
labels = [f"{freqs[i]} Hz (A={amps[i]})" for i in range(3)] + ["Composite"]

lines = []
for idx, (wave, color, label) in enumerate(zip(waves + [composite], colors, labels)):
    lines.append({
        "label": label,
        "data": [round(v, 4) for v in wave],
        "color": color
    })

plot_data = [{
    "title": "Three Sine Waves and Their Sum",
    "x_label": "Sample index",
    "y_label": "Amplitude",
    "x_data": list(range(N)),
    "lines": lines
}]
js.window.py_plot_data = json.dumps(plot_data)

print("Three sine waves at 3 Hz, 7 Hz, and 13 Hz are added together.")
print("The blue composite wave looks complex, but it is EXACTLY the sum of the three grey waves.")
print("Fourier analysis works in reverse: given only the blue wave, recover the three components.")

La señal compuesta parece irregular e impredecible, pero no contiene más información que tres frecuencias, tres amplitudes y tres fases. La idea de Fourier es que esta descomposición funciona al revés: dada únicamente la señal compuesta, podemos recuperar qué frecuencias están presentes y qué tan fuerte es cada una. La herramienta que hace esto es la Transformada Discreta de Fourier .

La Transformada Discreta de Fourier (DFT)

Dadas $N$ muestras equiespaciadas de una señal, la Transformada Discreta de Fourier (DFT) encuentra cuánto de cada frecuencia está presente. Lo hace correlacionando la señal con un conjunto de ondas seno y coseno de referencia — una para cada bin de frecuencia $k$. Si la señal contiene energía a la frecuencia $k$, la correlación es fuerte; si no, la correlación es cercana a cero.

La fórmula de la DFT es:

$$X[k] = \sum_{n=0}^{N-1} x[n] \cdot e^{-i \, 2\pi k n / N}, \quad k = 0, 1, \ldots, N-1$$

Desglosemos cada símbolo. $x[n]$ es la $n$-ésima muestra de la señal de entrada (un número real). $X[k]$ es el $k$-ésimo coeficiente de salida (un número complejo). $N$ es el número total de muestras. La exponencial $e^{-i \, 2\pi k n / N}$ es la fórmula de Euler disfrazada — equivale a $\cos(2\pi k n / N) - i \sin(2\pi k n / N)$, un punto giratorio en el círculo unitario del plano complejo. Al multiplicar cada muestra por esta referencia giratoria y sumar, estamos calculando la correlación entre la señal de entrada y una sinusoide a frecuencia $k$.

💡 El signo negativo en el exponente ($e^{-i\ldots}$) es una convención. Significa que estamos correlacionando con una exponencial compleja que rota en sentido horario. La DFT inversa usa $e^{+i\ldots}$ y divide por $N$ para reconstruir la señal original. Algunas referencias intercambian la convención de signo o colocan el factor $1/N$ de manera diferente — la matemática es equivalente siempre que las transformadas directa e inversa sean consistentes.

La salida $X[k]$ es compleja, y sus dos componentes nos dicen cosas diferentes:

  • Magnitud $|X[k]|$: cuánta energía hay presente en el bin de frecuencia $k$. Esto es lo que generalmente nos importa. Un pico en el espectro de magnitud significa que la señal contiene un componente fuerte a esa frecuencia.
  • Fase $\angle X[k]$: dónde en su ciclo comienza ese componente de frecuencia. Para la mayoría de las aplicaciones de aprendizaje automático (reconocimiento de voz, análisis musical), la fase lleva información perceptualmente menos importante, por lo que los modelos típicamente trabajan solo con magnitudes.

Verifiquemos el comportamiento en los límites. Cuando $k = 0$, la exponencial se convierte en $e^0 = 1$ para todo $n$, así que $X[0] = \sum x[n]$ — la suma de todas las muestras. Este es el componente DC (el valor promedio de la señal, escalado por $N$). Una señal centrada alrededor de cero tiene $X[0] \approx 0$. Cuando $k = N/2$ (si $N$ es par), la exponencial alterna entre $+1$ y $-1$, midiendo la frecuencia más alta representable en la señal — la frecuencia de Nyquist . Las frecuencias por encima de esta no se pueden distinguir de frecuencias más bajas (un fenómeno llamado aliasing), por eso el audio debe muestrearse a al menos el doble de la frecuencia más alta de interés.

Ahora implementemos la DFT desde cero usando solo el módulo cmath integrado de Python para aritmética compleja. La aplicaremos a nuestra señal compuesta de tres frecuencias y veremos si la DFT identifica correctamente los tres picos.

import math, cmath, json, js

# Build the same composite signal: 3 Hz + 7 Hz + 13 Hz
freqs  = [3, 7, 13]
amps   = [1.0, 0.6, 0.3]
sr = 64          # samples per second
N  = sr          # 1 second of data = 64 samples
t_vals = [n / sr for n in range(N)]
signal = [0.0] * N
for f, a in zip(freqs, amps):
    for n in range(N):
        signal[n] += a * math.sin(2 * math.pi * f * t_vals[n])

# ---- DFT from scratch ----
def dft(x):
    N = len(x)
    X = []
    for k in range(N):
        s = 0 + 0j
        for n in range(N):
            angle = -2 * math.pi * k * n / N
            s += x[n] * cmath.exp(1j * angle)
        X.append(s)
    return X

X = dft(signal)

# Magnitude spectrum (only first N/2 bins are meaningful for real signals)
half = N // 2
magnitudes = [abs(X[k]) / (N / 2) for k in range(half)]
freq_bins  = [k * sr / N for k in range(half)]

# Plot the magnitude spectrum
lines = [{
    "label": "DFT Magnitude",
    "data": [round(m, 4) for m in magnitudes],
    "color": "#3b82f6"
}]

plot_data = [{
    "title": "DFT Magnitude Spectrum",
    "x_label": "Frequency (Hz)",
    "y_label": "Amplitude",
    "x_data": [round(f, 1) for f in freq_bins],
    "lines": lines,
    "y_min": 0
}]
js.window.py_plot_data = json.dumps(plot_data)

print("DFT of composite signal (3 Hz + 7 Hz + 13 Hz):")
for f, a in zip(freqs, amps):
    k = int(f * N / sr)
    measured = abs(X[k]) / (N / 2)
    print(f"  Bin k={k} => freq={f} Hz, expected amplitude={a}, measured={measured:.4f}")

El espectro de magnitud muestra tres picos agudos exactamente a 3 Hz, 7 Hz y 13 Hz — las tres frecuencias que introdujimos. Las alturas coinciden con las amplitudes que usamos (1.0, 0.6, 0.3). La DFT ha descompuesto perfectamente nuestra señal compuesta en sus ondas sinusoidales constituyentes, tal como Fourier predijo. Todos los demás bins de frecuencia son esencialmente cero, confirmando que no hay otras frecuencias presentes.

La Transformada Rápida de Fourier (FFT)

Nuestra implementación de la DFT funciona, pero mira su estructura: para cada uno de los $N$ bins de salida, recorremos todas las $N$ muestras de entrada. Eso son $N \times N = N^2$ multiplicaciones complejas. Para un clip de audio de 1 segundo a 16 kHz ($N = 16{,}384$ muestras), eso son aproximadamente 268 millones de operaciones — demasiado lento para procesamiento en tiempo real.

En 1965, James Cooley y John Tukey publicaron un algoritmo (Cooley & Tukey, 1965) que calcula exactamente el mismo resultado en tiempo $O(N \log N)$. La idea: dividir la DFT de $N$ puntos en dos DFTs de $N/2$ puntos — una sobre las muestras de índice par, otra sobre las de índice impar — y combinar los resultados. Cada mitad puede dividirse de nuevo, recursivamente, hasta llegar a DFTs triviales de 1 punto. Esta es la Transformada Rápida de Fourier (FFT) , y es uno de los algoritmos más importantes en toda la computación.

La identidad matemática clave que hace posible esta división es:

$$X[k] = E[k] + e^{-i \, 2\pi k / N} \cdot O[k]$$
$$X[k + N/2] = E[k] - e^{-i \, 2\pi k / N} \cdot O[k]$$

donde $E[k]$ es la DFT de las muestras de índice par $x[0], x[2], x[4], \ldots$ y $O[k]$ es la DFT de las muestras de índice impar $x[1], x[3], x[5], \ldots$. El factor $e^{-i \, 2\pi k / N}$ se llama factor twiddle . Observa que ambas ecuaciones usan los mismos $E[k]$ y $O[k]$ — calculamos cada DFT de mitad de tamaño una vez y la reutilizamos dos veces (una con suma, otra con resta). Esta reutilización es lo que reduce el trabajo a la mitad en cada nivel de recursión.

💡 La FFT requiere que $N$ sea potencia de 2 para la versión más simple (radix-2). En la práctica, las tramas de audio siempre se eligen como potencias de 2 (256, 512, 1024, 2048) por esta razón. Las FFTs de radix mixto manejan otros tamaños, pero las potencias de 2 son las más rápidas.

¿Cuánto ahorra esto? En cada nivel de recursión, hacemos $O(N)$ trabajo (el paso de combinación), y recursamos $\log_2 N$ niveles de profundidad. Total: $O(N \log N)$. La tabla a continuación compara los conteos de operaciones para diferentes longitudes de señal:

import math, json, js

rows = []
for exp in [6, 8, 10, 12, 14, 16]:
    N = 2 ** exp
    dft_ops = N * N
    fft_ops = N * exp  # N * log2(N)
    speedup = dft_ops / fft_ops
    rows.append([
        f"{N:,}",
        f"~{exp * 1000 if exp <= 10 else ''}" if N <= 1024 else "",
        f"{dft_ops:,}",
        f"{fft_ops:,}",
        f"{speedup:,.0f}x"
    ])

# Cleaner rows with context
rows = []
for exp in [6, 8, 10, 12, 14, 16]:
    N = 2 ** exp
    dft_ops = N * N
    fft_ops = N * exp
    speedup = dft_ops / fft_ops
    ctx = ""
    if exp == 10:
        ctx = "~64 ms at 16 kHz"
    elif exp == 14:
        ctx = "~1 sec at 16 kHz"
    elif exp == 16:
        ctx = "~4 sec at 16 kHz"
    rows.append([
        f"{N:,}",
        ctx,
        f"{dft_ops:,}",
        f"{fft_ops:,}",
        f"{speedup:,.0f}x"
    ])

js.window.py_table_data = json.dumps({
    "headers": ["N (samples)", "Duration", "DFT ops (N\u00b2)", "FFT ops (N log N)", "Speedup"],
    "rows": rows
})

print("DFT vs FFT operation counts")
print(f"At N = 16,384 (1 sec of 16 kHz audio):")
print(f"  DFT: {16384**2:,} operations")
print(f"  FFT: {16384 * 14:,} operations")
print(f"  Speedup: {16384 // 14:,}x")

A $N = 16{,}384$ (un segundo de audio a 16 kHz), la FFT es más de 1,000 veces más rápida que la DFT. A $N = 65{,}536$ (cuatro segundos), es más de 4,000 veces más rápida. Sin la FFT, el análisis de audio en tiempo real sería computacionalmente inviable. Con ella, una CPU moderna puede procesar cientos de tramas FFT por segundo — lo suficientemente rápido para reconocimiento de voz en vivo, análisis musical y efectos de audio.

La Transformada de Fourier de Tiempo Corto (STFT)

La FFT nos dice qué frecuencias están presentes en una señal, pero nos da una sola respuesta para toda la señal. Eso es un problema para el audio, porque el audio cambia con el tiempo — una oración hablada comienza con un conjunto de frecuencias, transita por otros y termina con frecuencias completamente diferentes. Si aplicamos la FFT a toda la oración de una vez, obtenemos el contenido frecuencial promedio pero perdemos toda la información sobre cuándo cada frecuencia estuvo presente.

La solución es simple: cortar la señal en tramas cortas y superpuestas (típicamente de 25 ms cada una, avanzando 10 ms a la vez), y aplicar la FFT a cada trama independientemente. Esta es la Transformada de Fourier de Tiempo Corto (STFT) . Cada trama es lo suficientemente corta como para que la señal sea aproximadamente constante dentro de ella, así que la FFT de esa trama da una instantánea significativa del contenido frecuencial en ese momento.

Pero hay un compromiso fundamental acechando aquí, frecuentemente llamado el principio de incertidumbre tiempo-frecuencia :

$$\Delta t \cdot \Delta f \geq \frac{1}{4\pi}$$

donde $\Delta t$ es la resolución temporal (con qué precisión podemos localizar un evento en el tiempo) y $\Delta f$ es la resolución en frecuencia (con qué precisión podemos distinguir dos frecuencias cercanas). Esta desigualdad dice que no podemos tener resolución perfecta en ambas simultáneamente. Verifiquemos los límites:

  • Ventana larga ($\Delta t$ grande): más muestras por FFT significa bins de frecuencia más finos ($\Delta f$ pequeño), así que podemos distinguir frecuencias cercanas. Pero perdemos la capacidad de precisar cuándo sucedió algo — el evento podría estar en cualquier lugar dentro de la ventana larga.
  • Ventana corta ($\Delta t$ pequeño): sabemos con precisión cuándo sucedió algo, pero menos muestras por FFT significa bins de frecuencia más gruesos ($\Delta f$ grande), así que las frecuencias cercanas se difuminan.
  • Ventana infinitamente larga: $\Delta f \to 0$ (resolución en frecuencia perfecta), pero $\Delta t \to \infty$ (sin información temporal). Esto es simplemente la FFT regular de toda la señal.
  • Ventana infinitamente corta (una sola muestra): $\Delta t \to 0$ (resolución temporal perfecta), pero $\Delta f \to \infty$ (sin información de frecuencia). Solo tenemos la forma de onda cruda.

La ventana estándar de 25 ms a 16 kHz da $N = 400$ muestras por trama, lo que significa una resolución en frecuencia de $16{,}000 / 400 = 40$ Hz. Eso está bien para el habla (las frecuencias de formantes están separadas por cientos de Hz) pero es demasiado grueso para la detección precisa de tono musical (las teclas de piano adyacentes difieren solo unos pocos Hz en el registro grave).

Hay una segunda sutileza: no podemos simplemente cortar la señal con un rectángulo . Cuando extraemos una trama multiplicando la señal por una ventana rectangular (1 dentro de la trama, 0 fuera), los bordes abruptos al inicio y final de la trama actúan como discontinuidades artificiales. La FFT interpreta estas discontinuidades como contenido de alta frecuencia que en realidad no está en la señal — un fenómeno llamado fuga espectral . La energía que debería estar concentrada en un solo bin de frecuencia «se fuga» hacia bins vecinos, difuminando el espectro.

La solución es usar una función de ventana suave que se atenúa gradualmente a cero en los bordes. La elección más común es la ventana de Hann :

$$w[n] = 0.5 \left(1 - \cos\!\left(\frac{2\pi n}{N - 1}\right)\right), \quad n = 0, 1, \ldots, N-1$$

En los límites: cuando $n = 0$, $\cos(0) = 1$, así que $w[0] = 0.5(1 - 1) = 0$. Cuando $n = (N-1)/2$ (el punto medio), $\cos(\pi) = -1$, así que $w = 0.5(1 - (-1)) = 1$. Cuando $n = N-1$, $\cos(2\pi) = 1$, así que $w[N-1] = 0$. La ventana sube suavemente de 0 a 1 y vuelve a 0, asemejándose a una curva de campana. Multiplicar la señal por esta ventana antes de la FFT elimina los bordes abruptos y reduce drásticamente la fuga espectral.

La ventana Gaussiana tiene un propósito similar pero sigue una forma Gaussiana (curva de campana). Para una exploración más profunda de la distribución Gaussiana y sus propiedades, consulta el artículo de distribuciones de probabilidad .

El gráfico a continuación muestra la forma de la ventana de Hann, una trama de señal cruda y la misma trama después del ventaneo. Observa cómo la señal ventaneada decae suavemente a cero en los bordes, eliminando los saltos bruscos que causan fuga espectral.

import math, json, js

N = 128  # samples in one frame

# Hann window
hann = [0.5 * (1 - math.cos(2 * math.pi * n / (N - 1))) for n in range(N)]

# Create a signal frame: 5 Hz + 12 Hz sine waves
sr = 128
signal = [0.8 * math.sin(2 * math.pi * 5 * n / sr) + 0.5 * math.sin(2 * math.pi * 12 * n / sr) for n in range(N)]

# Windowed signal
windowed = [signal[n] * hann[n] for n in range(N)]

x_data = list(range(N))

plot_data = [{
    "title": "Hann Window and Its Effect on a Signal Frame",
    "x_label": "Sample index",
    "y_label": "Amplitude",
    "x_data": x_data,
    "lines": [
        {"label": "Hann window", "data": [round(v, 4) for v in hann], "color": "#f59e0b"},
        {"label": "Raw signal", "data": [round(v, 4) for v in signal], "color": "#94a3b8"},
        {"label": "Windowed signal", "data": [round(v, 4) for v in windowed], "color": "#3b82f6"}
    ]
}]
js.window.py_plot_data = json.dumps(plot_data)

print("The yellow Hann window tapers smoothly from 0 to 1 and back to 0.")
print("The grey raw signal has abrupt edges at sample 0 and sample 127.")
print("The blue windowed signal (raw * Hann) decays to zero at both edges.")
print("This eliminates spectral leakage caused by the rectangular window's sharp cuts.")

Veamos el impacto en el espectro de frecuencia directamente. Tomaremos la misma trama de señal, calcularemos la DFT con y sin la ventana de Hann, y compararemos los espectros de magnitud. La versión sin ventana muestra energía fugando a través de muchos bins, mientras que la versión ventaneada concentra la energía en picos agudos.

import math, cmath, json, js

N = 128
sr = 128

# Signal: 5 Hz + 12 Hz
signal = [0.8 * math.sin(2 * math.pi * 5 * n / sr) + 0.5 * math.sin(2 * math.pi * 12 * n / sr) for n in range(N)]

# Hann window
hann = [0.5 * (1 - math.cos(2 * math.pi * n / (N - 1))) for n in range(N)]
windowed = [signal[n] * hann[n] for n in range(N)]

# DFT helper
def dft(x):
    M = len(x)
    result = []
    for k in range(M):
        s = 0 + 0j
        for n in range(M):
            s += x[n] * cmath.exp(-2j * math.pi * k * n / M)
        result.append(s)
    return result

X_raw = dft(signal)
X_win = dft(windowed)

half = N // 2
# Normalise magnitudes
mag_raw = [abs(X_raw[k]) / (N / 2) for k in range(half)]
mag_win = [abs(X_win[k]) / (N / 2) * 2 for k in range(half)]  # x2 to compensate Hann amplitude loss
freq_bins = [k * sr / N for k in range(half)]

plot_data = [{
    "title": "Spectral Leakage: Rectangle vs Hann Window",
    "x_label": "Frequency (Hz)",
    "y_label": "Magnitude",
    "x_data": [round(f, 1) for f in freq_bins],
    "y_min": 0,
    "lines": [
        {"label": "No window (rectangle)", "data": [round(m, 4) for m in mag_raw], "color": "#ef4444"},
        {"label": "Hann window", "data": [round(m, 4) for m in mag_win], "color": "#3b82f6"}
    ]
}]
js.window.py_plot_data = json.dumps(plot_data)

print("Red (no window): energy leaks from the 5 Hz and 12 Hz peaks into neighbouring bins.")
print("Blue (Hann window): peaks are sharper and the noise floor is much lower.")
print("The Hann window reduces spectral leakage at the cost of slightly wider main peaks.")
💡 ¿Por qué funcionan las tramas superpuestas? Las tramas adyacentes comparten la mayoría de sus muestras (con ventanas de 25 ms y salto de 10 ms, las tramas vecinas se superponen un 60%). Debido a que la ventana de Hann atenúa los bordes de cada trama, la superposición asegura que cada parte de la señal esté a peso completo en al menos una trama. Esto se llama la propiedad de Constant Overlap-Add (COLA), y garantiza que la señal original pueda reconstruirse perfectamente a partir de la STFT si es necesario.

Leyendo un espectrograma

Cuando apilamos los espectros de magnitud de todas las tramas de la STFT lado a lado, obtenemos una matriz 2D llamada espectrograma . El eje x es el tiempo (una columna por trama), el eje y es la frecuencia (una fila por bin de frecuencia), y el valor en cada celda es la magnitud (o su logaritmo) de esa frecuencia en ese momento. Renderizado como imagen, los píxeles más brillantes significan más energía.

Así se leen los tres patrones visuales principales:

  • Bandas horizontales: un tono sostenido a frecuencia constante. Un violín sosteniendo una nota produce una línea horizontal brillante en la frecuencia fundamental, con líneas más tenues arriba en los armónicos.
  • Franjas verticales: un transitorio de banda ancha — energía en muchas frecuencias simultáneamente pero brevemente. Las consonantes como «t» y «k» en el habla, o un golpe de batería en música, aparecen como columnas verticales delgadas.
  • Formantes: en el habla, las frecuencias de resonancia del tracto vocal crean bandas horizontales amplias llamadas formantes. El espaciado y movimiento de los formantes es lo que distingue las vocales — «i» tiene un patrón de formantes diferente a «a» — y seguir los formantes a lo largo del tiempo revela la estructura del lenguaje hablado.

Para el aprendizaje automático, el espectrograma es revolucionario porque convierte una señal de audio 1D en una representación 2D — esencialmente una imagen del sonido. Y sabemos cómo procesar imágenes con CNNs y transformers. Por eso el espectrograma mel (una versión ponderada perceptualmente cubierta en el artículo 1) se convirtió en la entrada estándar para modelos de voz.

Construyamos un espectrograma desde cero. Crearemos una señal sintética donde las frecuencias cambian con el tiempo — un tono de 5 Hz presente en la primera mitad, un tono de 15 Hz en la segunda mitad, y un tono de 10 Hz durante todo el tiempo. El espectrograma debería mostrar claramente estos patrones.

import math, cmath, json, js

# Synthetic signal: 3 tones that turn on/off at different times
sr = 128      # samples per second
duration = 2  # seconds
N_total = sr * duration  # 256 samples

signal = [0.0] * N_total
for n in range(N_total):
    t = n / sr
    # 10 Hz present throughout
    signal[n] += 0.7 * math.sin(2 * math.pi * 10 * t)
    # 5 Hz present only in first half (0-1 sec)
    if t < 1.0:
        signal[n] += 1.0 * math.sin(2 * math.pi * 5 * t)
    # 15 Hz present only in second half (1-2 sec)
    if t >= 1.0:
        signal[n] += 0.8 * math.sin(2 * math.pi * 15 * t)

# STFT parameters
frame_len = 64
hop = 32  # 50% overlap
n_frames = (N_total - frame_len) // hop + 1

# Hann window
hann = [0.5 * (1 - math.cos(2 * math.pi * i / (frame_len - 1))) for i in range(frame_len)]

# Compute STFT
half = frame_len // 2
spectrogram = []
for f_idx in range(n_frames):
    start = f_idx * hop
    frame = [signal[start + i] * hann[i] for i in range(frame_len)]
    # DFT of frame
    mags = []
    for k in range(half):
        s = 0 + 0j
        for n in range(frame_len):
            s += frame[n] * cmath.exp(-2j * math.pi * k * n / frame_len)
        mags.append(abs(s) / (frame_len / 2) * 2)  # compensate Hann
    spectrogram.append(mags)

# Display as table: rows = selected frequency bins, cols = time frames
freq_bins = [k * sr / frame_len for k in range(half)]

# Select key frequencies to display
key_freqs = [0, 3, 5, 7, 10, 12, 15, 17, 20, 25, 30]
key_indices = []
for target in key_freqs:
    best_k = min(range(half), key=lambda k: abs(freq_bins[k] - target))
    if best_k not in [ki for ki, _ in key_indices]:
        key_indices.append((best_k, freq_bins[best_k]))

# Build table: rows are frequencies (high to low), columns are time frames
headers = ["Freq (Hz)"] + [f"t={f_idx * hop / sr:.2f}s" for f_idx in range(n_frames)]
rows = []
for k, freq in reversed(key_indices):
    row = [f"{freq:.0f} Hz"]
    for f_idx in range(n_frames):
        mag = spectrogram[f_idx][k]
        # Use symbols for magnitude
        if mag > 0.6:
            row.append(f"{mag:.2f} ***")
        elif mag > 0.2:
            row.append(f"{mag:.2f} *")
        else:
            row.append(f"{mag:.2f}")
    rows.append(row)

js.window.py_table_data = json.dumps({
    "headers": headers,
    "rows": rows
})

print("Spectrogram of a synthetic signal (magnitudes at each time-frequency cell)")
print()
print("*** = strong energy, * = moderate, blank = weak")
print()
print("Expected pattern:")
print("  5 Hz:  strong in first half (t < 1.0), gone in second half")
print("  10 Hz: strong throughout (present the entire 2 seconds)")
print("  15 Hz: gone in first half, strong in second half (t >= 1.0)")

Ahora visualicemos el mismo espectrograma como gráfico. Mostraremos la magnitud a lo largo del tiempo para cada uno de los tres bins de frecuencia clave, para que puedas ver los patrones de encendido/apagado claramente.

import math, cmath, json, js

# Rebuild the same signal and STFT
sr = 128
duration = 2
N_total = sr * duration
signal = [0.0] * N_total
for n in range(N_total):
    t = n / sr
    signal[n] += 0.7 * math.sin(2 * math.pi * 10 * t)
    if t < 1.0:
        signal[n] += 1.0 * math.sin(2 * math.pi * 5 * t)
    if t >= 1.0:
        signal[n] += 0.8 * math.sin(2 * math.pi * 15 * t)

frame_len = 64
hop = 32
n_frames = (N_total - frame_len) // hop + 1
hann = [0.5 * (1 - math.cos(2 * math.pi * i / (frame_len - 1))) for i in range(frame_len)]
half = frame_len // 2

spectrogram = []
for f_idx in range(n_frames):
    start = f_idx * hop
    frame = [signal[start + i] * hann[i] for i in range(frame_len)]
    mags = []
    for k in range(half):
        s = 0 + 0j
        for n in range(frame_len):
            s += frame[n] * cmath.exp(-2j * math.pi * k * n / frame_len)
        mags.append(abs(s) / (frame_len / 2) * 2)
    spectrogram.append(mags)

freq_bins = [k * sr / frame_len for k in range(half)]
time_vals = [round(f_idx * hop / sr, 2) for f_idx in range(n_frames)]

# Find bins closest to 5, 10, 15 Hz
targets = [5, 10, 15]
colors = ["#f59e0b", "#3b82f6", "#10b981"]
lines = []
for target, color in zip(targets, colors):
    best_k = min(range(half), key=lambda k: abs(freq_bins[k] - target))
    mags_over_time = [round(spectrogram[f_idx][best_k], 3) for f_idx in range(n_frames)]
    lines.append({
        "label": f"{freq_bins[best_k]:.0f} Hz bin",
        "data": mags_over_time,
        "color": color
    })

plot_data = [{
    "title": "Spectrogram: Energy at 5, 10, and 15 Hz Over Time",
    "x_label": "Time (seconds)",
    "y_label": "Magnitude",
    "x_data": time_vals,
    "y_min": 0,
    "lines": lines
}]
js.window.py_plot_data = json.dumps(plot_data)

print("Yellow (5 Hz): strong in the first second, drops to zero in the second.")
print("Blue (10 Hz): steady throughout — this tone is always present.")
print("Green (15 Hz): zero in the first second, rises in the second.")
print()
print("This is exactly what a spectrogram encodes: which frequencies are active at each point in time.")

Este es el poder del espectrograma STFT: revela la estructura frecuencial variable en el tiempo de una señal. Una sola FFT habría mostrado picos a 5, 10 y 15 Hz sin ninguna indicación de cuándo cada uno estuvo activo. El espectrograma preserva ambas dimensiones de información, y eso es lo que lo hace útil como entrada para modelos de audio.

Por qué esto importa para el deep learning

La transformada de Fourier nos dio la primera forma fundamentada de representar audio para el aprendizaje automático. En lugar de alimentar formas de onda crudas (que son solo una secuencia de mediciones de presión a lo largo del tiempo y no llevan información explícita de frecuencia), podemos alimentar un espectrograma — una imagen 2D tiempo-frecuencia — que hace legible la estructura de la señal para un modelo. Así es como las principales arquitecturas de audio usan esto:

  • Whisper (Radford et al., 2022): convierte audio crudo en un espectrograma log-mel de 80 canales usando STFT + banco de filtros mel, luego lo procesa con un transformer codificador-decodificador. El espectrograma mel es un paso de preprocesamiento fijo, no aprendido — todo el aprendizaje ocurre aguas abajo.
  • wav2vec 2.0 (Baevski et al., 2020): toma la forma de onda cruda como entrada, la pasa a través de un codificador de características CNN, luego un transformer. Las capas CNN aprenden implícitamente una representación similar a un espectrograma — la primera capa convolucional frecuentemente aprende filtros que se asemejan a sinusoides ventaneadas, redescubriendo efectivamente la transformada de Fourier a partir de los datos.
  • Codecs neuronales como EnCodec (D\'{e}fossez et al., 2022): codifican formas de onda crudas en tokens discretos usando una arquitectura aprendida de codificador-cuantizador-decodificador. El codificador aprende una representación comprimida (no necesariamente basada en frecuencia) que captura la estructura perceptualmente importante del audio en muchos menos bits que la señal cruda.

El espectrograma mel sigue siendo el formato de entrada más común para modelos de audio porque combina tres propiedades deseables: está fundamentado en procesamiento de señales bien entendido (la transformada de Fourier), está motivado perceptualmente (la escala mel coincide con la audición humana), y es compacto (80 canales mel vs. cientos de bins FFT crudos). Los modelos que parten de formas de onda crudas deben aprender estas propiedades de los datos, lo que requiere más computación y más ejemplos de entrenamiento.

Para resumir el pipeline desde el sonido hasta la entrada del modelo:

import json, js

rows = [
    ["1. Raw waveform", "1D array of pressure samples", "x[n], n = 0..N-1"],
    ["2. Framing", "Chop into overlapping 25 ms frames", "Frames of 400 samples (at 16 kHz)"],
    ["3. Windowing", "Multiply each frame by Hann window", "Smooth edges to zero"],
    ["4. FFT", "Frequency spectrum per frame", "Complex-valued X[k] per frame"],
    ["5. Magnitude", "|X[k]| gives energy at each freq", "Discard phase"],
    ["6. Mel filterbank", "Group FFT bins into mel-spaced bands", "80 mel channels (perceptual scale)"],
    ["7. Log", "log(mel energies)", "Compress dynamic range"],
    ["8. Model input", "2D matrix: time x mel channels", "Looks like an image of sound"],
]

js.window.py_table_data = json.dumps({
    "headers": ["Step", "What happens", "Result"],
    "rows": rows
})

print("Audio preprocessing pipeline: from raw waveform to mel spectrogram")
print("Each step builds on the Fourier transform concepts from this article.")

En el próximo artículo, veremos cómo modelos como Whisper y wav2vec 2.0 aprenden representaciones de audio a partir de datos — construyendo sobre la base espectral que hemos establecido aquí para lograr reconocimiento de voz, traducción y más de última generación.

Quiz

Pon a prueba tu comprensión de la transformada de Fourier y su papel en el procesamiento de audio.

¿Qué representa la magnitud $|X[k]|$ de la salida de la DFT en el bin $k$?

¿Por qué se aplica la ventana de Hann a cada trama antes de calcular la FFT?

Una ventana STFT más larga da mejor resolución en frecuencia pero peor resolución temporal. ¿Cómo se llama este compromiso?

La FFT calcula el mismo resultado que la DFT pero en $O(N \log N)$ en lugar de $O(N^2)$. ¿Cuál es la idea central que hace posible esta aceleración?