DEV Community

Guray
Guray

Posted on

Sesi modelden geri almak: karakter videolarında telaffuzu deterministik yapmak (Bölüm 3)

Bölüm 2'de karakter videosu hattını kurduğumuzu ama bir duvara çarptığımızı yazmıştım: video üreten servis konuşmayı da kendisi sentezliyordu ve Türkçeyi güvenilmez okuyordu. Aynı konu için altı klip çöpe gitti. Bu bölüm o duvarı nasıl yıktığımızın hikâyesi. Kısa cevap: sesi modelden geri aldık.

Sorun tam olarak neydi

Görsel taraf iyiydi. Karakter tutarlıydı, ışık güzeldi, kamera hareketi doğaldı. Sorun ağızdan çıkanlardı:

  • Kelime tekrarı: "bilim insanları ile birlikte de bilim insanları"
  • Modelin metni kendi kendine yeniden yazması: "insülin" geçen bir cümleyi baştan kurup temkinli bilim diline çevirdi
  • Yabancı kökenli kelimeleri uydurması: "eureka" → "ürika"
  • Anahtar kelimeyi yutması: "yayla" 0,26 saniyeye sıkışınca "aile" gibi duyuldu

Kalite kapımız faster-whisper large-v3 ile çalışıyor: klibi deşifre ediyoruz, kelime olasılığı 0,80'in altına düşen varsa klip reddediliyor.

parcalar, _ = model.transcribe(wav, language="tr", word_timestamps=True, beam_size=5)
for p in parcalar:
    for k in p.words or []:
        if k.probability is not None and k.probability < 0.80:
            zayif.append((k.word.strip(), round(k.probability, 2)))
Enter fullscreen mode Exit fullscreen mode

Bu kapı işini yapıyordu ama üretimin önünü tıkıyordu. Prompt mühendisliğiyle çözmeye çalıştık: "do not reword", "no stutter, no repeated words", "speak slowly, over-articulating every syllable", süreyi 10 saniyeden 12'ye çıkarmak. Hepsi reddi azalttı, hiçbiri bitirmedi. Çünkü sorun prompt değil, mimari: konuşmayı üretmesini istediğimiz model, aynı zamanda görüntüyü üreten model.

Önce yanlış yola saptık: GPU aramak

İlk içgüdü şuydu: ses güdümlü video modelleri var (Wan2.2-S2V, HunyuanVideo-Avatar, EchoMimicV3, OmniAvatar). Bunlara hazır ses veriyorsun, dudağı senin sesine göre üretiyorlar. Sunucuda GPU yok, o yüzden ücretsiz GPU saatleri olan bir not defteri servisine EchoMimicV3 kurmayı denedik.

Üç tur sürdü ve üçü de öğretici biçimde patladı:

  1. ffmpeg veri kümesindeki wav dosyasını açamadı. Kırpmayı Python tarafına (librosa + soundfile, 16 kHz yeniden örnekleme) taşıdık.
  2. Dosya yolları beklediğimiz yere bağlanmadı. Not defterini kendini iyileştirecek hâle getirdik: /kaggle/input altını os.walk ile tarayıp ilk .wav ve .jpg'yi bulan bir bölüm.
  3. Model yüklendi, çekirdek sessizce öldü. Dönüş kodu -9, yani SIGKILL.

Üçüncü turda gerçek teşhis log'un içindeydi:

### missing keys: 220;
['audio_injection.proj1.weight', 'audio_injection.proj1.bias', ...]
DONUS KODU: -9
Enter fullscreen mode Exit fullscreen mode

Yani ses enjeksiyon katmanının 220 ağırlığı hiç yüklenmemiş. Taban video modelinin ağırlıkları gelmiş, ses koşullama ağırlıkları gelmemiş. O hâliyle çalışsa bile ses görüntüyü sürmeyecekti. Buna ek olarak bellekten öldürülme. Bu, saatler yiyecek bir yapılandırma kazısı.

Mühendislik kararı: bu yolu kapattık. Not olarak duruyor, ileride GPU'lu bir ortam olursa geri dönülür.

Doğru yol: problemi ikiye ayırmak

Sonra bariz olanı gördük. Videodan istediğimiz iki şey var ve bunlar farklı zorluklar:

  • Sahne ve kimlik: karakterin yüzü, mekân, ışık, kamera. Bunda video modeli çok iyi.
  • Konuşma: doğru kelimeler, doğru telaffuz, doğru tempo. Bunda kötü.

Ayıralım. Görüntüyü model üretsin, konuşmayı bizim TTS motorumuz okusun, dudağı sonradan sese uyduralım. Böylece telaffuz determinist hâle gelir: metinde ne yazıyorsa o okunur.

Hat şöyle:

metin -> edge-tts (tr-TR) -> Whisper kapısı (0,80)
                                   |
taban klip (arşiv/model) ----------+--> Wav2Lip -> GFPGAN -> etiket -> kuyruk
Enter fullscreen mode Exit fullscreen mode

Ses: kotası olmayan bir motor

Site seslendirmesinde (yazıların sesli anlatımı) ticari bir motor kullanıyoruz, arşivin ton tutarlılığı için orayı değiştirmiyoruz. Ama 12 saniyelik video repliği için aylık karakter kotası yakmak saçma. Video dublajı için edge-tts kullanıyoruz: ücretsiz, kotasız, Türkçede iki ses (bir kadın, bir erkek).

Tek ayar kritik çıktı: hız.

python3 -m edge_tts --voice tr-TR-AhmetNeural --rate=-8% \
  --text "Bugün masaya ters bir kart düştü. ..." --write-media ses.mp3
Enter fullscreen mode Exit fullscreen mode

-4%'te Whisper üç kelimeyi eşiğin altında gördü (kart=0.73, Para=0.74, Falın=0.77). -8%'te hepsi geçti. Yani kendi TTS'imiz bile aceleye gelince deşifre edilemiyor; insan kulağı için de aynısı geçerli. Karakterleri ayırmak için aynı sesi perde kaydırmasıyla çeşitlendiriyoruz (--pitch=+10Hz genç ve parlak bir ton veriyor).

Dudak: Wav2Lip ve 96 piksel problemi

Dudak senkronu için Wav2Lip. CPU'da 12 saniyelik dikey klip yaklaşık 3 dakika, kabul edilebilir. Ama çıktıyı büyütünce sorun görünüyor: Wav2Lip ağız bölgesini 96x96'da üretiyor, 800 piksel genişliğindeki bir yüze yapıştırınca ağız lapa gibi kalıyor. Yayınlanabilir değil, çünkü zaten "yapay duruyor" eleştirisi almış bir üretim hattından bahsediyoruz.

Çözüm yüz onarımı: GFPGAN. CPU'da kare başına ~2,7 saniye. Ama naif kullanımı yeni bir sorun doğuruyor: her kareyi bağımsız onarırsan gözler, saç telleri, ten dokusu kare kare oynuyor ve video titriyor.

Numara şu: onarımı yalnızca Wav2Lip'in dokunduğu bölgeye harmanla. Dokunduğu bölgeyi bulmak için maske uydurmaya gerek yok, iki karenin farkı zaten maskenin kendisi:

fark  = cv2.absdiff(cv2.cvtColor(taban_kare, cv2.COLOR_BGR2GRAY),
                    cv2.cvtColor(w2l_kare,  cv2.COLOR_BGR2GRAY))
maske = (fark > 8).astype("uint8") * 255
maske = cv2.dilate(maske, np.ones((25, 25), "uint8"))
maske = cv2.GaussianBlur(maske, (61, 61), 0).astype("float32") / 255.0

son = taban_kare * (1 - maske) + onarilmis_kare * maske
Enter fullscreen mode Exit fullscreen mode

Genişletme ağız çevresine pay bırakıyor, bulanıklaştırma dikişi görünmez yapıyor. Ağız dışındaki her piksel orijinal kalıyor, dolayısıyla titreme yok. Karşılaştırmada onarılmış ağız keskinliği neredeyse kaynak kliple aynı seviyeye geliyor: dişler seçiliyor, sakal dokusu duruyor.

Taban klibi sese uydurmak

Ses tabandan uzun olursa Wav2Lip kareleri baştan döngüler ve ortada görünür bir kesik oluşur. Bunun yerine tabanı ffmpeg ile uzatıyoruz: hafif yavaşlatma artı sonun tersten oynatılması (bumerang).

ffmpeg -y -i taban.mp4 -filter_complex \
 "[0:v]hflip,setpts=1.15*PTS,fps=24,split[a][b];\
  [b]reverse,trim=start=0:end=2.6,setpts=PTS-STARTPTS[r];[a][r]concat=n=2:v=1" \
 -an -c:v libx264 -crf 14 taban-uzun.mp4
Enter fullscreen mode Exit fullscreen mode

Öğrendiklerimiz: %10-15 yavaşlatma fark edilmiyor, fazlası ağır çekim gibi duruyor. Yavaş sahnelerde tersten oynayan kuyruk göze batmıyor. hflip ise aynı tabanı ikinci kez kullanırken "başka çekim" hissi veriyor.

Beklenmedik kazanç: reddedilmiş klipler geri geldi

Bu hattın en tatlı yan etkisi şu: arşivdeki RED- önekli klipler ses yüzünden reddedilmişti, görüntüleri sağlamdı. Sesi tamamen değiştirdiğimize göre o klipler artık kullanılabilir. Aynı şekilde, indirilmiş ama hiç yayınlanmamış ham çekimler (kütüphane koridoru, akşam ışığında oturma odası, park yolu) birer "sahne kütüphanesi" hâline geldi.

Bir karakterin mekânı sabitse (günlük tarot bölümümüzün falcısı hep aynı mumlu masada çekiyor) taban klip her gün yeniden kullanılabiliyor. Sonuç: o bölümün videosu artık tarayıcıya hiç dokunmadan üretiliyor. Bir gecede aynı yöntemle dokuz klip kuyruğa girdi.

Sınırını da dürüstçe yazalım: tek karelik fotoğrafı dublajlamak işe yaramıyor. Göz kırpma olmadığı için 12 saniyede ölü bir maske gibi duruyor. Yeni bir mekân gerekiyorsa taban için hâlâ video modeline gidiyoruz.

Aynı gecenin iki önbellek kazası

Video hattıyla uğraşırken iki klasik ısırdı, ikisini de not düşüyorum.

404 önbelleği. Bir yazının görselini yüklemeyi geciktirmişsiniz ve o arada adres bir kez istenmiş. O "yok" cevabı iki katmanda kilitleniyor: nginx open_file_cache (60 saniye) ve CDN (4 saat). Dosyayı yükledikten sonra CDN önbelleğini temizlemek yetmiyor, çünkü temizlikten sonraki ilk istek nginx'in hâlâ elindeki negatif kaydı yeniden önbelleğe alıyor. Teşhis basit: curl -sI <url> cf-cache-status: HIT derken <url>?x=1 200 dönüyorsa budur. Doğru sıra: yükle, nginx'i reload et, sonra CDN'i temizle.

Beslemenin kaynak doğrusu. Podcast beslemesini üreten script yerel audio/ klasörünü tarıyordu. Seslendirme toplu işi ise mp3'ü geçici klasöre yazıp doğrudan sunucuya gönderiyordu. Sonuç: sunucuda 31 bölüm, yerelde 26, ve besleme her üretim turunda canlı feed'i 26'ya geri çekiyordu. Beş bölüm dağıtım platformuna hiç düşmemiş. Düzeltme tek satır (mp3'ü yerel klasöre yaz, oradan gönder) ama ders daha büyük: bir dosya iki yerde yaşıyorsa, hangisinin doğruyu söylediğini yazıya dökmek gerekiyor.

Sayılar

  • Dublaj hattı, 12 saniyelik dikey klip, 32 çekirdek CPU: Wav2Lip ~3 dk, GFPGAN ~15-25 dk, kodlama ~1 dk
  • Whisper kapısı: large-v3, eşik 0,80. Küçük modeller b/d gibi sesleri karıştırıp yanlış alarm veriyor, o yüzden küçültmüyoruz
  • TTS: 12 saniyelik replik için 1 istek, kota yok
  • Reddedilen klip sayısı bu hatta geçtikten sonra: sıfır (ses artık deterministik)

Sırada ne var

Yeni mekân üretimi hâlâ tarayıcıdaki video servisine bağlı, yani hattın tek insan/oturum bağımlılığı orada. Bir sonraki bölümde muhtemelen o bağımlılığı anlatacağım: ücretsiz API'sini kapatan bir platforma, oturum açık bir sekme üzerinden nasıl yayın yapılıyor ve bu neden hem çalışıyor hem de kırılgan.

Top comments (0)