Chapter sebelumnya membaca suara menjadi teks; text-to-speech (TTS) menempuh arah sebaliknya: teks masuk, keluaran berupa gelombang bunyi yang bisa langsung didengarkan. Arah yang terbalik ini menghadirkan masalah yang juga terbalik — teks pendek dan diskret, sedangkan audio panjang dan kontinu, dan panjang audio tidak bisa ditentukan dari jumlah huruf saja. Tantangan kedua bernama prosodi: kalimat yang sama dapat diucapkan sebagai pernyataan datar atau pertanyaan menaik, dan perbedaan itu tidak tertulis pada ejaannya. Chapter ini membedah pipeline TTS neural tahap demi tahap, lalu membangun TTS mainan yang menghasilkan berkas WAV sungguhan — lengkap dengan kontur nada yang berbeda untuk dua gaya ucap.
Tiga Tahap TTS Neural #
TTS modern lazim dipandang sebagai tiga komponen berurutan. Teks mentah lebih dulu dibersihkan: angka dan singkatan diubah menjadi kata (text normalization), lalu ejaan dipetakan ke bunyi melalui grapheme-to-phoneme (G2P). Model akustik menerima fitur linguistik itu dan memprediksi spektrogram mel per bingkai — representasi frekuensi-waktu yang sudah kita kenal dari chapter Audio-Text Model . Terakhir, vocoder menerjemahkan spektrogram menjadi waveform cuplik demi cuplik.
Tacotron 2 (arxiv.org/abs/1712.05884 ) memopulerkan pola ini dengan seq2seq yang memprediksi mel dan vocoder neural WaveNet (arxiv.org/abs/1609.03499 ) yang membentuk cuplik satu per satu secara autoregressive. Sistem satu model menyatukan ketiganya menyusul kemudian, misalnya VITS yang melatih model akustik dan vocoder bersama lewat adversarial training.
Peran tiap komponen dirangkum berikut:
| Komponen | Masukan -> keluaran | Bertanggung jawab atas |
|---|---|---|
| Normalisasi + G2P | teks -> fonem | “2 kg” menjadi “dua kilogram” |
| Model akustik | fonem -> mel per bingkai | durasi, nada, timbre pembicara |
| Vocoder | mel -> waveform | kualitas bunyi akhir (hiss, kejernihan) |
Prosodi: Melodi di Balik Kalimat #
Prosodi adalah lapisan informasi di atas ejaan: kontur frekuensi dasar (F0), durasi tiap segmen, dan energi. Bahasa Indonesia memakai perbedaan ini secara minimal — ujaran pernyataan lazimnya menurunkan F0 menjelang akhir, sedangkan pertanyaan menaikkannya. TTS parametrik klasik menuliskan aturan prosodi secara eksplisit; TTS neural mempelajari kontur itu dari data — model akustik melihat tanda baca akhir kalimat sebagai petunjuk gaya ucap, lalu membentuk mel yang sesuai. Jaringan ini pula yang harus menjaga dua kebutuhan yang saling berlawanan: bunyi tiap fonem mempertahankan identitas pembicara, sementara kontur keseluruhan mengikuti maksud kalimat.
Demo: TTS Mainan dari Teks ke WAV #
Demo kita memperkecil seluruh pipeline menjadi ratusan baris yang bisa dibaca utuh. Fitur linguistiknya berupa urutan karakter: vokal menjadi segmen bersuara yang membawa nada, konsonan menjadi potongan derau singkat. “Model akustik”-nya berupa tabel frekuensi dasar per vokal dikalikan faktor prosodi — menurun 15% untuk pernyataan, menaik 25% untuk pertanyaan. “Vocoder”-nya membentuk sinus dengan akumulasi fase lalu menyimpan hasilnya sebagai WAV 16-bit lewat modul wave bawaan Python (docs.python.org/3/library/wave.html
):
"""TTS mainan: teks mini -> fitur linguistik -> kontur frekuensi ->
waveform sinus -> berkas WAV lewat modul wave bawaan Python.
Pipeline meniru TTS neural tiga tahap pada skala terkecil:
1. Analisis linguistik -- tiap karakter diurai menjadi segmen vokal
(bersuara, membawa nada) atau konsonan (singkat, mirip derau).
2. Kontur prosodi -- frekuensi dasar vokal menurun untuk gaya
pernyataan dan menaik untuk gaya pertanyaan.
3. "Vocoder" mini -- waveform dibangkitkan per cuplik dengan
akumulasi fase agar nada meluncur mulus, dibungkus amplop
naik/turun 8 ms, lalu disimpan sebagai WAV 16-bit mono.
Berkas WAV ditulis lalu dibaca kembali untuk verifikasi round-trip,
kemudian dihapus agar bundle tetap bersih.
CPU, deterministik penuh (derau konsonan memakai seed tetap).
"""
import os
import wave
import numpy as np
rng = np.random.default_rng(42)
SR = 8000 # laju cuplik (Hz)
F0_DASAR = 440.0 # frekuensi dasar "pembicara" (Hz)
VOKAL_RASIO = {"a": 1.00, "e": 1.12, "i": 1.26, "o": 0.89, "u": 0.75}
DUR_VOKAL = 0.14 # detik per vokal
DUR_KONSONAN = 0.04 # detik per konsonan
RAMP_MS = 8.0 # panjang amplop naik/turun (ms)
def fitur_linguistik(kata):
"""Urai kata menjadi segmen: jenis, durasi, rasio frekuensi vokal."""
segmen = []
for ch in kata.lower():
if ch in VOKAL_RASIO:
segmen.append({"simbol": ch, "jenis": "vokal",
"durasi": DUR_VOKAL, "rasio": VOKAL_RASIO[ch]})
else:
segmen.append({"simbol": ch, "jenis": "konsonan",
"durasi": DUR_KONSONAN, "rasio": None})
return segmen
def faktor_prosodi(t, gaya):
"""Faktor kontur F0 pada posisi t di [0,1] sepanjang durasi vokal:
pernyataan menurun 15%, pertanyaan menaik 25%."""
if gaya == "pernyataan":
return 1.00 - 0.15 * t
return 1.00 + 0.25 * t
def amplop(gel):
"""Amplop linear naik-turun agar segmen tidak berpindah tajam."""
n_ramp = int(RAMP_MS / 1000.0 * SR)
env = np.ones_like(gel)
env[:n_ramp] = np.linspace(0.0, 1.0, n_ramp)
env[-n_ramp:] = np.linspace(1.0, 0.0, n_ramp)
return gel * env
def sintesis(kata, gaya):
"""Bangun waveform + tabel parameter per segmen untuk satu gaya."""
segmen = fitur_linguistik(kata)
total_vokal = sum(s["durasi"] for s in segmen if s["jenis"] == "vokal")
audio, param, berjalan = [], [], 0.0
for s in segmen:
n = int(s["durasi"] * SR)
if s["jenis"] == "konsonan":
gel = 0.05 * rng.standard_normal(n)
param.append((s["simbol"], "konsonan", s["durasi"], None, None))
else:
t0 = berjalan / total_vokal
t1 = (berjalan + s["durasi"]) / total_vokal
f0 = F0_DASAR * s["rasio"] * faktor_prosodi(
np.linspace(t0, t1, n, endpoint=False), gaya)
fase = 2.0 * np.pi * np.cumsum(f0) / SR # akumulasi fase
gel = amplop(0.8 * np.sin(fase))
param.append((s["simbol"], "vokal", s["durasi"],
f0[0], f0[-1]))
berjalan += s["durasi"]
audio.append(gel)
return np.concatenate(audio), param
print("=== 1. Analisis linguistik dan parameter frekuensi ===")
peta = " ".join(f"{k}={F0_DASAR * r:.0f}Hz" for k, r in VOKAL_RASIO.items())
print(f"peta vokal -> frekuensi dasar: {peta}")
audio_turun, param_t = sintesis("bata", "pernyataan")
audio_naik, param_q = sintesis("bata", "pertanyaan")
for gaya, param in (("pernyataan (F0 turun)", param_t),
("pertanyaan (F0 naik)", param_q)):
print(f"kata 'bata', gaya {gaya}:")
for simbol, jenis, durasi, f0a, f0z in param:
if jenis == "vokal":
print(f" {simbol} vokal {durasi:.2f} s "
f"F0 {f0a:.0f} -> {f0z:.0f} Hz")
else:
print(f" {simbol} konsonan {durasi:.2f} s (derau singkat)")
print()
print("=== 2. Waveform hasil sintesis ===")
for label, audio in (("pernyataan", audio_turun), ("pertanyaan", audio_naik)):
print(f"{label:12s}: {len(audio)} cuplik = {len(audio) / SR:.3f} s, "
f"puncak amplitudo {np.abs(audio).max():.3f}")
print("cuplik konsonan 'b' (derau):",
" ".join(f"{v:+.3f}" for v in audio_turun[:6]))
jenis_ke = [p[1] for p in param_t]
offset = int(sum(p[2] for p in param_t[:jenis_ke.index("vokal")]) * SR)
print("cuplik awal vokal 'a' (sinus + amplop):",
" ".join(f"{v:+.3f}" for v in audio_turun[offset:offset + 6]))
print()
print("=== 3. Prosodi terukur ===")
vokal_t = [p for p in param_t if p[1] == "vokal"]
vokal_q = [p for p in param_q if p[1] == "vokal"]
print(f"pernyataan: F0 rata-rata vokal pertama "
f"{(vokal_t[0][3] + vokal_t[0][4]) / 2:.0f} Hz -> vokal terakhir "
f"{(vokal_t[-1][3] + vokal_t[-1][4]) / 2:.0f} Hz (menurun)")
print(f"pertanyaan: F0 rata-rata vokal pertama "
f"{(vokal_q[0][3] + vokal_q[0][4]) / 2:.0f} Hz -> vokal terakhir "
f"{(vokal_q[-1][3] + vokal_q[-1][4]) / 2:.0f} Hz (menaik)")
print()
print("=== 4. Simpan dan verifikasi berkas WAV ===")
BERKAS = "tts_mini.wav"
data16 = (np.clip(audio_turun, -1.0, 1.0) * 32767).astype("<i2")
with wave.open(BERKAS, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes(data16.tobytes())
with wave.open(BERKAS, "rb") as w:
n_frame, lebar, sr_baca = (w.getnframes(), w.getsampwidth(),
w.getframerate())
baca16 = np.frombuffer(w.readframes(n_frame), dtype="<i2")
sama = bool(np.array_equal(baca16, data16))
galat = float(np.max(np.abs(audio_turun - baca16 / 32767.0)))
print(f"berkas {BERKAS}: {os.path.getsize(BERKAS)} byte, {n_frame} cuplik, "
f"sampwidth {lebar} byte, laju {sr_baca} Hz")
print(f"round-trip int16 identik: {sama}, "
f"galat kuantisasi maksimum {galat:.2e}")
os.remove(BERKAS)
print(f"berkas {BERKAS} dihapus; isi bundle kembali bersih")Output yang Dihasilkan:
=== 1. Analisis linguistik dan parameter frekuensi ===
peta vokal -> frekuensi dasar: a=440Hz e=493Hz i=554Hz o=392Hz u=330Hz
kata 'bata', gaya pernyataan (F0 turun):
b konsonan 0.04 s (derau singkat)
a vokal 0.14 s F0 440 -> 407 Hz
t konsonan 0.04 s (derau singkat)
a vokal 0.14 s F0 407 -> 374 Hz
kata 'bata', gaya pertanyaan (F0 naik):
b konsonan 0.04 s (derau singkat)
a vokal 0.14 s F0 440 -> 495 Hz
t konsonan 0.04 s (derau singkat)
a vokal 0.14 s F0 495 -> 550 Hz
=== 2. Waveform hasil sintesis ===
pernyataan : 2880 cuplik = 0.360 s, puncak amplitudo 0.800
pertanyaan : 2880 cuplik = 0.360 s, puncak amplitudo 0.800
cuplik konsonan 'b' (derau): +0.015 -0.052 +0.038 +0.047 -0.098 -0.065
cuplik awal vokal 'a' (sinus + amplop): +0.000 +0.008 +0.022 +0.037 +0.050 +0.056
=== 3. Prosodi terukur ===
pernyataan: F0 rata-rata vokal pertama 424 Hz -> vokal terakhir 391 Hz (menurun)
pertanyaan: F0 rata-rata vokal pertama 467 Hz -> vokal terakhir 522 Hz (menaik)
=== 4. Simpan dan verifikasi berkas WAV ===
berkas tts_mini.wav: 5804 byte, 2880 cuplik, sampwidth 2 byte, laju 8000 Hz
round-trip int16 identik: True, galat kuantisasi maksimum 3.05e-05
berkas tts_mini.wav dihapus; isi bundle kembali bersihMari kita telaah kode di atas baris per baris:
- Baris 33-43 (
fitur_linguistik): inilah tahap analisis linguistik versi mini — setiap karakter diurai menjadi segmen berjenis vokal atau konsonan dengan durasi tetap. Pada TTS nyata tahap ini berisi normalisasi teks dan G2P, tetapi prinsipnya sama: teks dipecah menjadi satuan bunyi berparameter. - Baris 46-51 (
faktor_prosodi): kontur prosodi dinyatakan sebagai faktor pengali F0 yang bergantung posisitdi sepanjang durasi vokal — 1,00 turun ke 0,85 untuk pernyataan, naik ke 1,25 untuk pertanyaan. - Baris 54-60 (
amplop): amplop linear 8 ms menjaga amplitudo naik dan turun secara halus di tepi segmen; tanpa amplop, sambungan antar segmen menghasilkan bunyi berderik (klik) karena lompatan amplitudo mendadak. - Baris 63-84 (
sintesis): inti “vocoder” mini.np.cumsum(f0)mengakumulasi frekuensi menjadi fase — teknik ini menjaga gelombang tetap mulus ketika frekuensi bergeser perlahan, alih-alih menggantisin(2πft)per segmen yang meninggalkan lompatan fase di batas. Posisiberjalan/total_vokalmemastikan kontur prosodi berjalan menyeberangi seluruh kata, bukan berulang per huruf. - Baris 87-100: tabel parameter dicetak untuk kedua gaya — perhatikan baris
ayang sama menghasilkan rentang F0 berbeda karena posisinya dalam kontur berbeda. - Baris 127-143: waveform dikuantisasi ke int16 (
* 32767, tipe"<i2"), ditulis denganwave, lalu dibaca kembali. Pemeriksaanarray_equalmembuktikan round-trip berkas sempurna, sedangkangalat 3.05e-05mengukur konsekuensi kuantisasi 16-bit — tepat di bawah1/32767. - Baris 144-145: berkas dihapus agar bundle chapter tetap bersih; pembaca yang menjalankan ulang skrip ini akan mendapatkan berkas yang sama karena seluruh proses deterministik.
Keluaran pada bagian tiga adalah bukti prosodi yang kita cari: kata “bata” yang sama menghasilkan kontur menurun 424 → 391 Hz pada gaya pernyataan dan menaik 467 → 522 Hz pada gaya pertanyaan. Ukuran berkas 5.804 byte juga bisa diperiksa dengan hitungan tangan: 2.880 cuplik × 2 byte + 44 byte header WAV. Enam cuplik pertama vokal a (+0.000 +0.008 +0.022 ...) memperlihatkan amplop naik bekerja — amplitudo tumbuh bertahap dari nol, bukan melompat.
Dengan audio yang kini mengalir ke dua arah (teks ke suara dan suara ke teks), satu modalitas terakhir belum tersentuh: gerak. Chapter Video Understanding menambahkan dimensi waktu pada citra — video sebagai deretan bingkai yang harus dibaca sebagai satu kejadian, bukan sebagai foto-foto terpisah.