Penglihatan dan bahasa sudah menyatu pada dua chapter sebelumnya; kini giliran modalitas yang paling alami bagi manusia: suara. Tantangan pertamanya bukan pada model, melainkan pada representasi — gelombang bunyi hanyalah barisan angka amplitudo yang panjang dan tampak tanpa struktur. Bab ini membangun intuisinya dari nol: kita membungkus sinyal menjadi bingkai-bingkai pendek, menghitung spektrogram magnitudo dengan FFT, lalu memakai hasilnya sebagai fitur untuk model klasifikasi kecil yang benar-benar dilatih. Arsitektur encoder-decoder audio ke teks seperti Whisper dibahas sebagai perluasan konseptual dari representasi yang sama, dan penutupnya memetakan tiga arah model audio-text yang aktif dikembangkan saat ini.

dari Waveform ke Spektrogram #

Sebuah nada murni 220 Hz pada laju cuplik 2000 Hz tersimpan sebagai 500 angka antara -1 dan 1. Jika kita menghitung FFT atas seluruh sinyal, frekuensi nadanya terbaca sebagai satu puncak — tetapi ucapan nyata berubah frekuensinya sepanjang waktu, sehingga satu FFT global mencampur semuanya. Solusinya adalah spektrogram: sinyal diiris menjadi bingkai pendek (misalnya 128 cuplik dengan lompatan 64), lalu FFT dihitung per bingkai. Hasilnya adalah matriks waktu-x-frekuensi yang siap diperlakukan seperti gambar oleh convolutional network, atau seperti urutan token oleh transformer.

Sistem produksi menambahkan dua penyempurnaan di atas ide ini. Filterbank mel memetakan frekuensi ke skala yang meniru persepsi telinga manusia — resolusi rapat di frekuensi rendah, renggang di frekuensi tinggi — dan MFCC (mel-frequency cepstral coefficients) memampatkan hasilnya lewat discrete cosine transform menjadi belasan koefisien per bingkai. Kedua langkah itu tidak mengubah prinsip dasarnya: energi per pita frekuensi, per potongan waktu.

Demo: Spektrogram Mini dan Klasifikasi Nada #

Program berikut mengerjakan dua hal secara berurutan. Bagian pertama menghitung spektrogram tiga nada murni dan mencetak bin puncaknya — angka yang bisa kita verifikasi dengan hitungan tangan. Bagian kedua melatih nn.Linear kecil untuk mengklasifikasi nada berdasarkan spektrum magnitudonya, dengan frekuensi yang sengaja diberi variasi ±12 Hz:

python
"""Audio sebagai data: dari waveform sinus ke spektrogram magnitudo,
lalu klasifikasi nada oleh model kecil.

Bagian 1 (NumPy): tiga nada murni 220/440/880 Hz pada laju cuplik
2000 Hz dibingkai (window 128, hop 64) dan ditransformasi FFT --
spektrogram magnitudo berukuran nyata dicetak bersama bin puncak
tiap nada.

Bagian 2 (PyTorch): 90 nada latih + 30 nada uji dengan amplitudo,
fase, dan frekuensi sedikit berubah-ubah; fitur = spektrum magnitudo
seluruh sinyal; model Linear kecil mengklasifikasi tiga kelas nada.
CPU, deterministik penuh. PyTorch 2.14 + NumPy 2.1.
"""

import numpy as np
import torch
import torch.nn as nn

rng = np.random.default_rng(42)
torch.manual_seed(42)

SR = 2000          # laju cuplik (Hz)
DURASI = 0.25      # detik
FREKUENSI = [220.0, 440.0, 880.0]
WINDOW, HOP = 128, 64


def buat_nada(frekuensi, amplitudo, fase, generator):
    """Waveform sinus murni + derau kecil, 500 cuplik."""
    t = np.arange(int(SR * DURASI)) / SR
    sinyal = amplitudo * np.sin(2 * np.pi * frekuensi * t + fase)
    return sinyal + 0.05 * generator.standard_normal(sinyal.shape)


def spektrogram(sinyal):
    """Bingkai window 128 hop 64 -> FFT magnitudo tiap bingkai."""
    n_frame = 1 + (len(sinyal) - WINDOW) // HOP
    baris = []
    for i in range(n_frame):
        frame = sinyal[i * HOP: i * HOP + WINDOW]
        baris.append(np.abs(np.fft.rfft(frame)))
    return np.stack(baris)


print("=== 1. Tiga nada murni dan spektrogramnya ===")
for f in FREKUENSI:
    sinyal = buat_nada(f, 1.0, 0.0, rng)
    sp = spektrogram(sinyal)
    bin_puncak = int(sp[0].argmax())
    frek_puncak = bin_puncak * SR / WINDOW
    print(f"nada {f:6.1f} Hz: {len(sinyal)} cuplik, spektrogram "
          f"{sp.shape}, puncak bin {bin_puncak} ({frek_puncak:.1f} Hz), "
          f"magnitudo {sp[0, bin_puncak]:.1f}")

sinyal_220 = buat_nada(220.0, 1.0, 0.0, rng)
sp_220 = spektrogram(sinyal_220)
print("energi bin 0-4 (nada 220 Hz, bingkai pertama):",
      " ".join(f"{v:.1f}" for v in sp_220[0, :5]))
print()


def fitur_spektrum(sinyal):
    """Fitur akustik: FFT magnitudo seluruh sinyal (251 bin)."""
    return np.abs(np.fft.rfft(sinyal))


print("=== 2. Dataset klasifikasi nada ===")


def buat_dataset(n_per_kelas, generator):
    X, y = [], []
    for kelas, f in enumerate(FREKUENSI):
        for _ in range(n_per_kelas):
            f_jitter = f + generator.uniform(-12, 12)
            amp = generator.uniform(0.5, 1.0)
            fase = generator.uniform(0, 2 * np.pi)
            X.append(fitur_spektrum(buat_nada(f_jitter, amp, fase, generator)))
            y.append(kelas)
    return np.stack(X), np.array(y)


X_latih, y_latih = buat_dataset(30, np.random.default_rng(1))
X_uji, y_uji = buat_dataset(10, np.random.default_rng(2))
print(f"latih: {X_latih.shape}, uji: {X_uji.shape} "
      f"(fitur = {X_latih.shape[1]} bin frekuensi)")

x_latih = torch.tensor(X_latih, dtype=torch.float32)
y_latih_t = torch.tensor(y_latih)
x_uji = torch.tensor(X_uji, dtype=torch.float32)
y_uji_t = torch.tensor(y_uji)

print()
print("=== 3. Pelatihan model klasifikasi nada ===")
model = nn.Sequential(
    nn.Linear(X_latih.shape[1], 16), nn.Tanh(), nn.Linear(16, 3))
opt = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = nn.CrossEntropyLoss()
jejak = []
for e in range(300):
    opt.zero_grad()
    loss = loss_fn(model(x_latih), y_latih_t)
    loss.backward()
    opt.step()
    if e in (0, 149, 299):
        jejak.append(loss.item())
print("loss: epoch 0 {:.4f} -> epoch 150 {:.4f} -> epoch 300 {:.4f}"
      .format(*jejak))

with torch.no_grad():
    ak_uji = (model(x_uji).argmax(1) == y_uji_t).float().mean().item()
    ak_latih = (model(x_latih).argmax(1) == y_latih_t).float().mean().item()
print(f"akurasi latih: {ak_latih:.3f}, akurasi uji: {ak_uji:.3f}")

with torch.no_grad():
    logit = model(x_uji[:1])[0]
    p = torch.softmax(logit, dim=0)
    frek_teoritis = FREKUENSI[int(y_uji[0])]
print(f"contoh uji: nada sejati {frek_teoritis:.0f} Hz -> prediksi "
      f"{FREKUENSI[int(p.argmax())]:.0f} Hz (p={p.max():.2f})")
print("probabilitas tiga kelas:",
      " ".join(f"{f:.0f}Hz={v:.2f}" for f, v in zip(FREKUENSI, p.tolist())))

Output yang Dihasilkan:

text
=== 1. Tiga nada murni dan spektrogramnya ===
nada  220.0 Hz: 500 cuplik, spektrogram (6, 65), puncak bin 14 (218.8 Hz), magnitudo 63.5
nada  440.0 Hz: 500 cuplik, spektrogram (6, 65), puncak bin 28 (437.5 Hz), magnitudo 61.8
nada  880.0 Hz: 500 cuplik, spektrogram (6, 65), puncak bin 56 (875.0 Hz), magnitudo 52.8
energi bin 0-4 (nada 220 Hz, bingkai pertama): 1.0 0.7 0.6 0.2 0.9

=== 2. Dataset klasifikasi nada ===
latih: (90, 251), uji: (30, 251) (fitur = 251 bin frekuensi)

=== 3. Pelatihan model klasifikasi nada ===
loss: epoch 0 1.2341 -> epoch 150 0.0017 -> epoch 300 0.0007
akurasi latih: 1.000, akurasi uji: 1.000
contoh uji: nada sejati 220 Hz -> prediksi 220 Hz (p=1.00)
probabilitas tiga kelas: 220Hz=1.00 440Hz=0.00 880Hz=0.00

Mari kita telaah kode di atas baris per baris:

  1. Baris 28-33 (buat_nada): np.sin(2πft + fase) menghasilkan gelombang murni; amplitudo, fase, dan derau 0,05 nanti divariasikan agar model tidak menghafal satu waveform persis.
  2. Baris 35-45 (spektrogram): jumlah bingkai dihitung dari panjang sinyal, window, dan hop; np.fft.rfft menghitung FFT nyata sehingga 128 cuplik menghasilkan 65 bin frekuensi (0 hingga 1000 Hz, lebar bin 15,625 Hz); np.abs mengambil magnitudo.
  3. Baris 56-58 (fitur_spektrum): untuk klasifikasi, seluruh 500 cuplik ditransformasi sekaligus menjadi 251 bin — cukup karena nada uji kita tidak berubah frekuensinya sepanjang waktu.
  4. Baris 70-82 (buat_dataset): frekuensi diberi jitter ±12 Hz, amplitudo antara 0,5-1,0, dan fase acak penuh; ketiga variasi ini membuat tugas klasifikasi bergantung pada lokasi puncak spektrum, bukan pada bentuk waveform mentah.
  5. Baris 94-107: model hanyalah MLP dua lapis dengan 16 unit tersembunyi; 300 epoch Adam dengan lr 0,01 menurunkan loss dari 1,2341 ke 0,0007.

Angka pada bagian pertama dapat diperiksa dengan hitungan tangan. Nada 220 Hz dengan lebar bin 2000/128 = 15,625 Hz jatuh pada bin 220/15,625 ≈ 14,08 — keluaran menunjukkan bin 14 dengan frekuensi baca 218,8 Hz, tepat di dekat target. Nada 880 Hz jatuh pada bin 56 (875,0 Hz). Pergeseran kecil ini adalah efek kuantisasi bin, dan pada spektrogram bersolusi lebih tinggi (window lebih panjang) puncaknya makin presisi. Klasifikasinya sendiri pun sehat: akurasi uji 1,000 dengan probabilitas 1,00 pada kelas yang benar, meski frekuensi setiap sampel bergeser ±12 Hz dari nilai idealnya.

Tiga Arah Model Audio-Text #

ArahMasukan -> keluaranContoh arsitekturKegunaan khas
Speech-to-textaudio -> teksencoder-decoder transformer (Whisper)transkripsi rapat, dikte
Text-to-speechteks -> audiomodel akustik + vocoderpembaca layar, narasi
Audio-text gabunganpasangan audio+teks -> representasi bersamapretraining kontrastif lintas modalitaspencarian suara, klasifikasi audio zero-shot

Whisper (paper arXiv 2212.04356, kode di github.com/openai/whisper ) melatih encoder-decoder transformer pada sekitar 680 ribu jam audio beserta transkripnya — konsep kerjanya persis skala lanjutan dari demo kita: audio menjadi spektrogram mel 80 bin, lalu encoder membaca urutan bingkai itu layaknya transformer membaca token. Sisi text-conditioned — menghasilkan audio yang mengikuti deskripsi teks — menjadi inti chapter Text-to-Speech .

Dari representasi audio yang sudah terbentuk, langkah berikutnya adalah tugas audio-text yang paling banyak dipakai sehari-hari: mengubah ucapan menjadi teks. Chapter Speech Recognition membedah pipeline ASR lengkap — dari fitur akustik, alignment berbasis CTC, hingga simulasi transkripsi pola nada yang bisa kita jalankan dari awal sampai akhir.