Chapter sebelumnya menutup pada representasi audio: spektrogram mel yang dibaca layaknya urutan token. Automatic Speech Recognition (ASR) memakai representasi itu untuk tugas yang paling banyak dipakai sehari-hari: mengubah ucapan menjadi teks. Tantangan intinya bukan mengenali bunyi secara terpisah, melainkan memetakan ratusan bingkai audio ke segelintir karakter tanpa tahu bingkai mana yang sesuai dengan karakter mana — masalah alignment. Chapter ini membedah pipeline ASR, membangun intuisi CTC sebagai solusi alignment paling berpengaruh, lalu menjalankan ASR mainan tiga tahap yang benar-benar dilatih: pola nada morse ditranskripsi menjadi kata dengan akurasi terukur.

Pipeline ASR: dari Cuplik ke Karakter #

Sistem ASR modern tersusun atas empat tahap. Audio mentah terlalu panjang dan redundan untuk dimasukkan langsung ke model, sehingga front-end mengirisnya menjadi bingkai pendek (10-25 ms) dan menghitung fitur akustik per bingkai — log-mel spektrogram seperti yang kita bangun di chapter Audio-Text Model . Encoder akustik (RNN, CNN, atau Transformer) membaca urutan bingkai dan menghasilkan distribusi label per bingkai; decoder merangkai label-label itu menjadi teks, biasanya dibantu model bahasa.

Kerangka ini sama untuk keluarga model yang tampak berbeda. Whisper (arXiv 2212.04356) memakai encoder-decoder Transformer penuh yang dilatih pada 680 ribu jam audio bertranskrip, sedangkan wav2vec 2.0 (arxiv.org/abs/2006.11477 ) mempelajari representasi bingkai secara self-supervised, lalu menyempurnakannya dengan sedikit data berlabel.

Masalah Alignment dan Intuisi CTC #

Mengapa alignment menjadi masalah? Kata “SOS” terdiri atas tiga karakter, tetapi ucapannya menghasilkan ratusan bingkai fitur. Data latih hanya memberi pasangan (audio, teks) — tidak ada anotasi “bingkai 47 sampai 112 milik huruf S” — dan menganotasi manual jelas tidak terjangkau.

Connectionist Temporal Classification (CTC) menghilangkan kebutuhan anotasi itu dengan tiga aturan. Pertama, model mengeluarkan distribusi per bingkai atas kosakata karakter ditambah satu token khusus: blank. Kedua, alignment harus monotonic — karakter hanya boleh maju seiring waktu, karena ucapan memang diucapkan berurutan. Ketiga, teks akhir diperoleh lewat dua langkah collapse: gabungkan label berulang yang bertetangga, lalu buang semua blank:

text
frame    :  1   2   3   4   5   6   7   8   9   10  11  12
keluaran :  -   -   S   S   -   O   O   -   S   -   -   -

langkah 1 (gabung berulang) : -  S  -  O  -  S  -
langkah 2 (buang blank)     : S O S

Token blank menjalankan dua peran: memberi ruang bagi model untuk “tidak menyebut apa-apa” pada bingkai hening, dan menjadi pemisah wajib antar karakter kembar — tanpa blank, deretan S S tergabung menjadi satu S oleh aturan collapse. Loss CTC menjumlahkan probabilitas semua jalur alignment sah menuju teks yang sama, sehingga model bebas memilih tempo: jalur lambat dan jalur cepat sama-sama dihargai. Pembahasan matematis lengkapnya ada di monograf Graves (arxiv.org/abs/1508.01282 ).

Demo: ASR Mainan Tiga Tahap #

Demo kita menerapkan pipeline penuh pada domain mini: kata dieja dalam kode morse, disintesis menjadi audio nada 600 Hz, lalu ditranskripsi kembali. Tiga tahapnya sejajar dengan pipeline nyata — front-end segmentasi energi meniru voice activity detection, model akustik MLP mengklasifikasi tiap segmen menjadi empat kelas (nada pendek dit, nada panjang dah, jeda pendek, jeda panjang), dan decoder morse berperan sebagai kamus bahasa:

python
"""ASR mainan: transkripsi pola nada (morse) menjadi teks.

Pipeline meniru ASR klasik tiga tahap:
1. Front-end -- deteksi segmen nada/hening dari energi bingkai 20 ms
   (padanan VAD pada ASR nyata).
2. Model akustik -- MLP kecil terlatih mengklasifikasi tiap segmen
   menjadi 4 kelas: dit (nada pendek), dah (nada panjang), hening
   pendek (jeda dalam huruf), hening panjang (jeda antar huruf).
   Fitur segmen: [durasi ms, rms].
3. Decoder -- pemetaan deterministik: kumpulkan titik/garis sampai
   jeda panjang, cari huruf di tabel morse, gabungkan jadi kata.

Uji nyata: 30 kata uji (SOS/TEST/SITE/EAST) dengan jitter durasi,
amplitudo acak, dan derau -- akurasi kata dan huruf dicetak.
CPU, PyTorch 2.14, deterministik penuh.
"""

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

torch.manual_seed(5)

SR = 2000
FREK_NADA = 600.0
DUR = {"dit": 0.06, "dah": 0.18, "gap_pendek": 0.06, "gap_panjang": 0.18}
MORSE = {"E": ".", "T": "-", "I": "..", "A": ".-",
         "S": "...", "O": "---"}
KATA_UJI = ["SOS", "TEST", "SITE", "EAST"]


def sintesis_kata(kata, generator):
    """Gabungkan nada/gap per huruf menjadi satu waveform."""
    potongan = []
    for huruf in kata:
        for simbol in MORSE[huruf]:
            potongan.append(("nada", "dit" if simbol == "." else "dah"))
            potongan.append(("hening", "gap_pendek"))
        # huruf selesai: ganti jeda terakhir menjadi jeda panjang
        potongan[-1] = ("hening", "gap_panjang")
    potongan = potongan[:-1]  # tidak perlu jeda di akhir audio
    audio = []
    for jenis, kelas in potongan:
        durasi = DUR[kelas] * generator.uniform(0.85, 1.15)
        n = int(durasi * SR)
        if jenis == "nada":
            amp = generator.uniform(0.4, 0.6)
            t = np.arange(n) / SR
            s = amp * np.sin(2 * np.pi * FREK_NADA * t)
        else:
            s = np.zeros(n)
        s = s + 0.05 * generator.standard_normal(n)
        audio.append(s)
    return np.concatenate(audio)


def segmen_dari_energi(audio, ambang=0.12):
    """Front-end: bingkai 20 ms, energi rata-rata |x|, lalu segmen."""
    frame = 40  # 20 ms pada SR 2000
    n_frame = len(audio) // frame
    energi = np.abs(audio[: n_frame * frame].reshape(n_frame, frame)).mean(1)
    aktif = energi > ambang
    segmen, mulai = [], 0
    for i in range(1, n_frame + 1):
        if i == n_frame or aktif[i] != aktif[mulai]:
            potongan = audio[mulai * frame: i * frame]
            segmen.append({
                "nada": bool(aktif[mulai]),
                "durasi_ms": (i - mulai) * frame * 1000 / SR,
                "rms": float(np.sqrt(np.mean(potongan ** 2))),
            })
            mulai = i
    return segmen


def fitur_segmen(seg):
    """Fitur [durasi/100, rms] untuk model akustik."""
    return [seg["durasi_ms"] / 100.0, seg["rms"]]


# ---------- data latih model akustik: segmen sintetis ----------
def buat_segmen_latih(n_per_kelas, generator):
    X, y = [], []
    label_ke_id = {"dit": 0, "dah": 1, "gap_pendek": 2, "gap_panjang": 3}
    for kelas, idx in label_ke_id.items():
        target_dur = DUR[kelas]
        nada = kelas in ("dit", "dah")
        for _ in range(n_per_kelas):
            durasi = target_dur * generator.uniform(0.85, 1.15)
            n = int(durasi * SR)
            if nada:
                amp = generator.uniform(0.4, 0.6)
                t = np.arange(n) / SR
                s = amp * np.sin(2 * np.pi * FREK_NADA * t)
            else:
                s = np.zeros(n)
            s = s + 0.05 * generator.standard_normal(n)
            X.append([durasi * 1000 / 100.0,
                      float(np.sqrt(np.mean(s ** 2)))])
            y.append(idx)
    return (torch.tensor(X, dtype=torch.float32),
            torch.tensor(y), {v: k for k, v in label_ke_id.items()})


print("=== 1. Model akustik: klasifikasi 4 kelas segmen ===")
X_a, y_a, id_ke_label = buat_segmen_latih(200, np.random.default_rng(1))
model_akustik = nn.Sequential(nn.Linear(2, 16), nn.Tanh(), nn.Linear(16, 4))
opt = torch.optim.Adam(model_akustik.parameters(), lr=0.02)
loss_fn = nn.CrossEntropyLoss()
for e in range(300):
    opt.zero_grad()
    loss = loss_fn(model_akustik(X_a), y_a)
    loss.backward()
    opt.step()
X_b, y_b, _ = buat_segmen_latih(40, np.random.default_rng(2))
with torch.no_grad():
    ak_segmen = (model_akustik(X_b).argmax(1) == y_b).float().mean().item()
print(f"latih {X_a.shape[0]} segmen, loss akhir {loss.item():.4f}, "
      f"akurasi segmen uji: {ak_segmen:.3f}")

ID_KE_SIMBOL = {"dit": ".", "dah": "-", "gap_pendek": None, "gap_panjang": "|"}
MORSE_REV = {v: k for k, v in MORSE.items()}
NAMA_PENDEK = {"gap_pendek": "jedaP", "gap_panjang": "jedaL"}


def transkripsi(audio):
    """Pipeline lengkap: front-end -> model akustik -> decoder morse."""
    segmen = segmen_dari_energi(audio)
    fitur = torch.tensor([fitur_segmen(s) for s in segmen],
                         dtype=torch.float32)
    with torch.no_grad():
        pred = model_akustik(fitur).argmax(1).tolist()
    kelas = [id_ke_label[p] for p in pred]
    simbol, huruf, jejak = [], [], []
    for s, k in zip(segmen, kelas):
        jejak.append((s["nada"], round(s["durasi_ms"]),
                      NAMA_PENDEK.get(k, k)))
        if k in ("dit", "dah"):
            simbol.append(ID_KE_SIMBOL[k])
        elif k == "gap_panjang" and simbol:
            huruf.append(MORSE_REV.get("".join(simbol), "?"))
            simbol = []
    if simbol:
        huruf.append(MORSE_REV.get("".join(simbol), "?"))
    return "".join(huruf), jejak


print()
print("=== 2. Transkripsi 30 kata uji ===")
gen_uji = np.random.default_rng(7)
benar_kata = benar_huruf = total_huruf = 0
contoh_dicetak = 0
for i in range(30):
    kata = KATA_UJI[i % len(KATA_UJI)]
    audio = sintesis_kata(kata, gen_uji)
    hasil, jejak = transkripsi(audio)
    benar_kata += hasil == kata
    benar_huruf += sum(a == b for a, b in zip(hasil, kata))
    total_huruf += len(kata)
    if contoh_dicetak < 2:
        print(f"contoh: kata sejati '{kata}' -> transkripsi '{hasil}' "
              f"({len(jejak)} segmen)")
        print("  segmen (nada, ms, kelas):",
              " ".join(f"{n}:{d}ms/{k}" for n, d, k in jejak[:8]),
              "..." if len(jejak) > 8 else "")
        contoh_dicetak += 1
print(f"akurasi kata : {benar_kata}/30 = {benar_kata / 30:.3f}")
print(f"akurasi huruf: {benar_huruf}/{total_huruf} = "
      f"{benar_huruf / total_huruf:.3f}")

Output yang Dihasilkan:

text
=== 1. Model akustik: klasifikasi 4 kelas segmen ===
latih 800 segmen, loss akhir 0.0021, akurasi segmen uji: 1.000

=== 2. Transkripsi 30 kata uji ===
contoh: kata sejati 'SOS' -> transkripsi 'SOS' (17 segmen)
  segmen (nada, ms, kelas): True:60ms/dit False:60ms/jedaP True:60ms/dit False:60ms/jedaP True:60ms/dit False:200ms/jedaL True:180ms/dah False:40ms/jedaP ...
contoh: kata sejati 'TEST' -> transkripsi 'TEST' (11 segmen)
  segmen (nada, ms, kelas): True:180ms/dah False:180ms/jedaL True:60ms/dit False:180ms/jedaL True:60ms/dit False:40ms/jedaP True:80ms/dit False:40ms/jedaP ...
akurasi kata : 30/30 = 1.000
akurasi huruf: 112/112 = 1.000

Mari kita telaah kode di atas baris per baris:

  1. Baris 32-54 (sintesis_kata): fungsi ini merakit audio uji — setiap simbol morse menjadi potongan nada atau hening dengan durasi dikalikan jitter acak 0,85-1,15, amplitudo 0,4-0,6, ditambah derau 0,05. Ketiga variasi itulah yang membuat transkripsi menjadi tugas nyata, bukan pencocokan pola persis.
  2. Baris 57-73 (segmen_dari_energi): front-end menghitung energi rata-rata nilai absolut per bingkai 20 ms lalu memotong sinyal setiap kali status aktif (di atas ambang 0,12) berganti. Durasi terkuantisasi kelipatan 20 ms — pada keluaran terlihat jedaP terukur 40 ms dan 60 ms dari target yang sama, efek resolusi bingkai yang juga terjadi pada ASR nyata.
  3. Baris 82-102 (buat_segmen_latih): model akustik dilatih bukan pada audio kata utuh, melainkan pada 800 segmen sintetis berlabel dengan fitur dua dimensi: durasi (dibagi 100 agar skala wajar) dan RMS.
  4. Baris 105-119: MLP dua lapis dilatih 300 epoch dan mencapai akurasi segmen uji 1,000 — lapisan akustik terbukti sehat sebelum dipakai end-to-end.
  5. Baris 126-145 (transkripsi): pipeline lengkap dirangkai — segmen difiturkan, diklasifikasi, lalu decoder deterministik mengumpulkan ./- hingga gap_panjang, mencari huruf di tabel morse, dan mengembalikan ? untuk pola yang tidak dikenal (padanan kata di luar kosakata pada ASR nyata).
  6. Baris 149-169: 30 kata uji melewati pipeline penuh; akurasi dihitung pada dua granularitas — kata dan huruf — meniru pengukuran WER tingkat kata dan karakter.

Dua baris contoh pada keluaran memperlihatkan mekanismenya secara terbuka: kata SOS menjadi 17 segmen, dan jejak True:60ms/dit hingga False:200ms/jedaL menunjukkan ketiga tahap bekerja berurutan pada data yang sama. Hasil akhirnya 30/30 kata dan 112/112 huruf — pesannya bukan angka sempurna itu, melainkan keputusan akustik lokal dirangkai menjadi keputusan linguistik global tanpa satu pun anotasi per bingkai, persis semangat CTC.

Fusi Model Bahasa pada Decoding #

Model akustik hanya melihat bunyi, padahal teks harus masuk akal secara bahasa. Kata “masa” dan “massa” terdengar hampir sama, dan pemilihan di antara keduanya tidak bisa diputuskan dari spektrum saja. Praktik modern karena itu menggabungkan model bahasa (language model, LM) ke tahap decoding, dengan tiga pola utama:

PendekatanMekanismeCiri khas
Shallow fusionskor log ASR dan log LM dijumlahkan (dengan bobot) saat beam searchLM dapat ditukar tanpa melatih ulang ASR
Deep fusionrepresentasi ASR dan LM digabung sebelum lapisan softmaxinteraksi lebih kaya, dilatih bersama
Cold fusionfitur LM dilatih lebih dulu lalu disuntik ke decoderadaptasi ke domain baru lebih stabil

Kualitas LM lazim diukur dengan perplexity yang telah kita hitung manual di chapter Evaluasi Model NLP . Pada era model bahasa besar, rescoring kandidat transkripsi dengan LLM menjadi kelanjutan alami dari shallow fusion.

Arah masukan kini bisa dibalik: alih-alih membaca suara menjadi teks, kita menulis teks lalu membangkitkan suara. Chapter Text-to-Speech membedah pipeline ke arah sebaliknya — dari fitur linguistik hingga waveform yang bisa disimpan sebagai berkas audio sungguhan.