İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Telifsiz Oyun Müziği: Bütün Sesleri Kodla Üretmek

Ahmet Balaman

8 dk okuma

Vibe CodingKalecikGodotPythonSes SenteziClaude
Telifsiz Oyun Müziği: Bütün Sesleri Kodla Üretmek

Kalecik'te duyduğunuz hiçbir ses bir kayıttan gelmiyor. Taşın taşa oturduğu "tok" sesi, uzaktaki kuşlar, gece cırcır böcekleri, göletin kıyıya vuran suyu, koyunun melemesi ve arka plandaki müziğin tamamı bir Python betiğinin içinde, sayılardan üretildi. Hazır ses paketi, örnek (sample) ya da lisans yok. Kalecik, iPhone ve iPad için hazırladığım sakin bir kale ve köy kurma oyunu; bu yazı onun seslerinin nasıl yapıldığını anlatıyor.

Baştan söyleyeyim: kodu Claude Code ajanları yazdı. Ben telefonda dinledim, neyin rahatsız ettiğini söyledim ve neyin kalacağına karar verdim. İşin ilginç yanı, sesleri yazan ajanın onları duyamamasıydı; yazının bir bölümü bu yüzden "duymadan ses yapmak" üzerine. Projenin bütününü merkez yazıda anlattım.

Neden kayıt değil de sentez?

Ses kaydı kullanmak iki soru getirir: lisansı ne, oyunun havasına uyuyor mu? Sentezde ikisi de ortadan kalkıyor. tools/make_audio.py'nin ilk satırı işi özetliyor: "Synthesise every sound the game uses (no samples, no licences)". Betik yalnızca numpy kullanıyor, MP3'e çevirmek için lame çağırıyor. Sesler 22.050 Hz ve mono; hepsi toplamsal ve modal sentez, FFT ile filtreleme ve FFT evrişimli yankıdan oluşuyor.

Kalecik’te akşam ışığında köy

Akşam: müzik de bu saatin destesinden çalıyor.

Modal sentezi en kolay taş sesinde görebilirsiniz. Vurulan bir cisim, her biri kendi hızında sönen birkaç frekansta titreşir. Bu frekansları ve sönme sürelerini seçtiğinizde cismi de seçmiş olursunuz:

def modal(freqs, decays, amps, seconds):
    """Vurulan cisim: üstel olarak sönen birkaç sinüs modu."""
    t = tt(seconds)
    y = np.zeros(len(t))
    for f, d, a in zip(freqs, decays, amps):
        if f < 0.45 * SR:
            y += a * np.sin(2 * np.pi * f * t) * np.exp(-t / d)
    return y

def stone_place(seed):
    """Bir taşın ötekinin üstüne oturması: yumuşak bir 'tok' ve biraz kum."""
    r = np.random.default_rng(seed)
    s = r.uniform(0.85, 1.15)                      # her varyant biraz farklı boyda bir taş
    y = modal([95 * s, 230 * s, 520 * s, 880 * s, 1400 * s], [0.07, 0.06, 0.035, 0.02, 0.012],
              [0.8, 1.0, 0.55, 0.3, 0.15], 0.4)
    y += click(0.4, 300, 3000, 0.005) * 0.25       # çarpmanın ilk anı
    y += fft_filter(rng.normal(0, 1, len(y)), lo=600, hi=3500) * np.exp(-tt(0.4) / 0.06) * 0.08   # kum
    return outdoor(fft_filter(y, hi=4500), 0.12)

Seslerin çoğu bir tohum alıyor. Tohum değişince taşın boyu değişiyor; thud, thud_2, thud_3 gibi varyantlar böylece bedavaya geliyor. Oyun her seferinde rastgele bir varyant seçiyor ama aynısını art arda iki kez çalmıyor.

Kuş, cırcır ve su

Doğa sesleri de aynı küçük parçalardan kuruluyor. Kuş sesi, frekansı kısa sürede kayan bir sinüs (chirp); birkaç yükselen chirp art arda gelince cıvıltı, çift inişler gelince başka bir tür çıkıyor. Cırcır böceği, yaklaşık 4 kHz'lik 14 ms'lik atımların düzenli tekrarı. Göletin kıyıya vurması, bant geçiren filtreden geçmiş gürültü ile frekansı yukarı kayan birkaç kabarcık. Hepsi en sonda outdoor() fonksiyonundan geçiyor: üstel sönen gürültüden yapılmış kısa bir dürtü yanıtıyla evrişim. Bu adım sesi açık havaya, biraz uzağa koyuyor.

Koyun sesi: formantlarla ikinci deneme

İlk koyun sesi kötüydü. Telefonda dinlerken yazdığım mesaj aşağı yukarı şuydu: "baa.wav ile baa_2.wav feci kötü, koyun sesiyle alakası yok, rahatsız etmeyecek şekilde yeniden seslendir."

İkinci denemede ajan, insan sesi için kullanılan kaynak-filtre modeline döndü. Gırtlak düzenli darbeler üretir; bu darbelerin harmonikleri ağız ve burun boşluğunun rezonanslarından, yani formantlardan geçer. Hangi ünlüyü duyduğumuzu bu rezonansların yeri belirler. Koyunun "meee" sesi için yaklaşık 700, 2100 ve 2900 Hz'de üç formant, başta burundan bir "m" mırıltısı ve 7-9 Hz'lik bir titreme kullanıldı (kısaltılmış):

def baa(seed):
    """Bir tarla ötedeki koyun: burundan bir 'm', titreyen bir 'eee'ye açılır."""
    r = np.random.default_rng(seed)
    d = r.uniform(0.7, 0.95)
    t = tt(d)
    rate = r.uniform(7.0, 9.0)                                   # 7-9 Hz titreme
    f0 = r.uniform(215, 260) * (1 + 0.035 * np.sin(2 * np.pi * rate * t))
    ph = 2 * np.pi * np.cumsum(f0) / SR
    src = np.zeros(len(t))
    k = 1
    while k * 280 < 5000:                                        # gırtlak darbesi: harmonik dizisi
        src += np.sin(k * ph) / k ** 1.3
        k += 1
    vowel = (resonator(src, r.uniform(650, 800), 5) * 1.0        # F1 ~700 Hz
             + resonator(src, r.uniform(1900, 2300), 8) * 0.45   # F2 ~2100 Hz
             + resonator(src, r.uniform(2700, 3000), 10) * 0.15) # F3 ~2900 Hz
    nasal = resonator(src, 260, 3) * 0.8                         # baştaki 'm'
    open_ = np.clip((t - 0.05) / 0.08, 0, 1)                     # m'den e'ye geçiş
    trem = 1 - 0.45 * (0.5 + 0.5 * np.sin(2 * np.pi * rate * t + np.pi))
    y = (nasal * (1 - open_) + vowel * open_) * trem
    # ... gerçek kodda ayrıca nefes, zarf ve bant filtresi var
    return outdoor(y, 0.35)

Koyun artık gündüz 70-160 saniyede bir, uzaktan ve kısık meliyor. Bu sesin yeterince iyi olduğuna ajan değil, ben karar verdim; ajanın elinde yalnızca sayılar vardı.

Hiçbir şey döngü değil

İlk sürümde rüzgâr sesi bir döngü olarak çalıyordu. Telefonda şöyle yazmıştım: "Arka plandaki seslerde bir sorun var, deniz sesi sürekli takılı kalıyor gibi. Deniz mi var oyunda?" Sentezlenmiş, sürekli bir rüzgâr gürültüsü kulağa dalga sesi gibi geliyordu; döngünün başa sardığı an da büyük ihtimalle o takılma hissini veriyordu.

Kalecik’in müziği: dört saat için dört karıştırılmış deste, parçalar arasında 30-110 saniye sessizlik, döngü yok

Çözüm bir kural oldu: oyunda hiçbir ses döngü değil. audio.gd'nin başındaki açıklama bunu açıkça söylüyor: "Nothing loops, so there is no loop seam to hear." Ortam sesi, rastgele zamanlanmış tek seferlik seslerden oluşuyor ve her ruh halinde her sesin kendi bekleme aralığı var:

## Ruh hali başına ortam sesi: [ses, en kısa ara s, en uzun ara s, dB, perde oynaması]
const AMBIENCE := [
	[["bird", 5.0, 14.0, -21.0, 0.06], ["leaves", 14.0, 30.0, -25.0, 0.15], ["baa", 70.0, 160.0, -28.0, 0.05]],   # gündüz
	[["bird", 12.0, 28.0, -24.0, 0.06], ["cricket", 6.0, 12.0, -30.0, 0.04], ["baa", 120.0, 240.0, -29.0, 0.05]], # akşam
	[["cricket", 2.0, 4.0, -27.0, 0.05], ["owl", 45.0, 100.0, -24.0, 0.04]],                                        # gece
	[["bird", 30.0, 60.0, -27.0, 0.05]],                                                                             # kış
]

Her çalışta perde hafifçe oynatılıyor ve bir sonraki zaman aralıktan yeniden çekiliyor. Yaprak hışırtısında bilerek hiç bas yok; koddaki not "so it never reads as surf" diyor. Kamera bir gölete bakıyorsa su sesi devreye giriyor, kışın göletler donduğu için susuyor. Yağmur bile döngü değil: 7 saniyelik, iki ucu yumuşakça açılıp kapanan katmanlar 5,5 saniyede bir üst üste başlatılıyor. Yağmurda kuşlar ve koyunlar da çoğunlukla susuyor.

Müzik de aynı mantıkla çalıyor. Her ruh hali (gündüz, akşam, gece, kış) için parçalar karıştırılmış bir desteden dağıtılıyor; bir parça ancak ötekilerin hepsi çaldıktan sonra geri geliyor:

func _deal() -> String:
	var deck: Array = _decks[_mood]
	if deck.is_empty():
		deck.append_array(_pieces[_mood])
		deck.shuffle()
		if deck.size() > 1 and deck.back() == _last_piece:   # aynı parça art arda gelmez
			deck.push_front(deck.pop_back())
	return deck.pop_back() if not deck.is_empty() else ""

İki parça arasında gündüz 30-75, gece ve kışın 45-110 saniye sessizlik kalıyor. Sesler üç ayrı bus'ta çalıyor: Music, SFX ve Ambience. Ayarlardaki müzik ve efekt anahtarları bu bus'ları susturuyor.

Üretken besteci: 37 parça, yaklaşık 65 dakika

İlk müzik elle yazılmış altı parçaydı: sabah, çayır, vals, gün batımı, gece ve kar. Sonra ajana şu hedefi verdim: "Öyle bir müzik yap ki 2 saat dinlesen de sıkmayacak ve boğmayacak bir şey olsun."

Kalecik’te kış: karla kaplı köy

Kış modu: çan ve arp ağırlıklı, sakin parçalar.

Ajanın cevabı tools/music_gen.py oldu: kurallara dayalı bir besteci. Her parça bir tohumdan yazılıyor; tohum tonu, modu, tempoyu, ölçüyü, akor kalıbını, çalgıları ve bir motife dayalı melodiyi seçiyor:

class Composer:
    def __init__(self, mood, seed):
        self.cfg = MOODS[mood]
        self.r = np.random.default_rng(seed)      # parçanın bütün kararları bu tohumdan
        r, c = self.r, self.cfg
        self.mode = str(r.choice(c["modes"]))     # majör, lidyen, dorian...
        self.tonic = 60 + int(r.choice(c["keys"]))
        if self.tonic > 60:
            self.tonic -= 12
        self.meter = int(r.choice(c["meters"]))   # 3/4 ya da 4/4
        self.bpm = float(r.uniform(*c["bpm"]))
        leads = list(c["leads"])
        r.shuffle(leads)
        self.lead_a, self.lead_b = leads[0], leads[1]   # tekrarda melodi başka çalgıya geçer
        # ... akor kalıpları ve eşlik de aynı tohumdan seçiliyor

Melodiler halk ezgileri gibi söylenebilir kalsın diye sıkı kurallarla yazılıyor. Temel birim 8 ölçülük bir dönem: ilk dört ölçü bir soru gibi yükselip yarım kadansta durur, son dört ölçü motifi hatırlatıp tonikte kapanır. Notalar hep dizinin içinde kalıyor, güçlü vuruşlar akor seslerine düşüyor, melodi çoğunlukla adım adım ilerliyor, bir sıçramadan sonra geri adım atıyor ve tizlere çıkmıyor. Form da ruh haline göre değişiyor; gündüz parçaları giriş, A, süslemeli A', B, nefes, A'' ve bitişten oluşuyor.

Ruh hali Mod Tempo Öndeki çalgılar
Gündüz majör, lidyen, miksolidyen 70-90 BPM flüt, piyano, arp
Akşam majör, lidyen (7'li akorlar) 58-70 BPM piyano, flüt
Gece dorian, aeolian 50-60 BPM celesta, müzik kutusu, piyano
Kış majör, lidyen 54-64 BPM glockenspiel, celesta, flüt

Çalgılar da sentez. Flüt, gecikmeli vibratolu, neredeyse saf bir ton ve bant sınırlı bir nefes; piyano, akordu hafifçe ayrık iki tel; arp ve gitar, üst harmonikleri daha hızlı sönen koparılmış tel. Sonuç 6'sı elle, 31'i besteciyle yazılmış 37 parça (14 gündüz, 9 akşam, 7 gece, 7 kış), toplam yaklaşık 65 dakika ve 19 MB MP3. Yani 2 saatlik benzersiz müzik yok; ama parçalar arasındaki uzun sessizlikler ve her turda yeniden karılan sıra, uzun bir oturumda aynı dizinin tekrarlanmasını engelliyor.

Müzik neden kesiliyordu? Jeton ve bekçi

Aynı mesajda bir hata da bildirmiştim: "Belli bir süre sonra müzik bir anda kesiliyor." Ajan sebebi kod incelemesinde buldu. Ruh hali değişirken çalan parça 3 saniyede kısılıyor, kısma bitince stop() çağrılıyordu. Parça bu 3 saniye içinde kendiliğinden biterse yeni bir parça başlıyor, eski kısma işleminin stop()'u da yeni parçayı aniden kesiyordu. Çözüm bir jeton oldu: her yeni parça bir sayacı artırıyor, eski bir kısma işlemi jeton değişmişse hiçbir şeye dokunmuyor:

var token := _token
_fade = create_tween()
_fade.tween_property(_music, "volume_db", -50.0, 3.0)
_fade.tween_callback(func():
	if token == _token:   # bu arada yeni bir parça başladıysa ona dokunma
		_music.stop())

Ek güvenlik olarak bir bekçi eklendi. iOS'ta bir arama, Siri ya da uygulamanın arka plana geçmesi sesi durdurabiliyor. Bekçi her karede bakıyor: parça çalıyor görünüp konumu 2 saniyedir ilerlemiyorsa ya da parça bitmeden durduysa, kaldığı yerden kısa bir açılışla devam ettiriyor:

if _music.playing:
	var pos := _music.get_playback_position()
	_stall = _stall + delta if absf(pos - _last_pos) < 0.0005 else 0.0
	_last_pos = pos
	if _stall > 2.0:          # çalıyor görünüyor ama saat ilerlemiyor: dürt
		_stall = 0.0
		_resume()
elif _last_pos < _piece_len - 1.5:
	_resume()                  # parça bitmeden durdu

Bu bölümün paralel çalışmayla ilgili bir dersi de var. Dışarıdan aldığım bir GPT incelemesi, müzik dosyalarıyla kodun bir ara uyuşmadığını yakaladı: yeni parçalar üretilirken eski MP3'ler, onları arayan kod güncellenmeden silinmişti. Sonrasında sıra hep aynı kaldı: önce yeni dosya, sonra kod, en son eskisini silmek.

Duymadan ses yapmak: LUFS ve spektrogram

Projenin en tuhaf kısıtı buydu: sesleri yazan ajan onları duyamıyordu. Kontrol için iki araca dayandı. Birincisi ses yüksekliği ölçümü: insan kulağının duyarlılığına göre ağırlıklandırılmış bir ölçü olan LUFS, ffmpeg'in ebur128 filtresiyle her parça için ölçüldü; aynı komut tepe değerini de veriyor. İkincisi spektrogram: ffmpeg her parçanın frekans-zaman resmini PNG olarak çizdi, ajan da bu resimlere bakıp tizlerin ve gürültünün nerede durduğunu değerlendirdi:

cd audio/music
for f in *.mp3; do
  printf "%-14s " "$f"
  ffmpeg -hide_banner -nostats -i "$f" -af ebur128=peak=true -f null - 2>&1 \
    | grep -E "^\s+(I|LRA|Peak):" | tr -s ' ' | tr '\n' ' '
  echo
done
ffmpeg -loglevel error -y -i gunduz_01.mp3 -lavfi "showspectrumpic=s=1200x400:legend=0:scale=log" spec.png

Besteci her parçayı ruh haline göre bir hedef RMS seviyesine getiriyor, sonra yumuşak bir sınırlayıcıdan (tanh) geçiriyor. Elle yazılmış parçalar biraz yüksek çıktığı için ruh hali başına ayrı bir kazançla aynı seviyeye çekildi. Ölçülen sonuç: gündüz yaklaşık -22,5, akşam -23,3, gece ve kış -24 LUFS. İlk ölçüm turunda flütün nefes gürültüsü de daha dar bir banda ve daha düşük seviyeye çekildi.

Yine de son söz kulağın. Sayılar parçaların birbiriyle tutarlı olduğunu söylüyor, güzel olup olmadıklarını söylemiyor. Bu yüzden her ses değişikliğini telefonda dinledim. Küçük bir not: testler sırasında Mac'ten ses gelmesi arka planda çalışırken beni rahatsız edince, bütün otomatik testler Godot'nun --audio-driver Dummy seçeneğiyle sessiz çalışmaya başladı.

Sessiz mod

Son konu teknik değil, bir karardı. Telefon sessiz moddayken oyun tamamen susuyordu. Ben tersini istedim: "Telefon sessizde bile olsa sesi açtırabilir miyiz?" iOS'ta bunu uygulamanın ses oturumu kategorisi belirliyor. Godot'da iki satırlık bir ayar (project.godot):

[audio]

general/ios/session_category=3
general/ios/mix_with_others=true

3, "Playback" kategorisi: ses, sessiz moda rağmen çalar. mix_with_others ise oyunun, arkada çalan başka bir uygulamanın sesini kesmeden onunla karışmasını sağlıyor. Bu tartışmaya açık bir tercih; birçok oyun sessiz moda uyar. Kalecik'te bunun karşılığı ayarlarda: müzik ve efektler ayrı ayrı kapatılabiliyor.

Kalecik iPhone ve iPad için geliştiriliyor; sürüm 1.0 App Store incelemesine hazırlanıyor, Android sürümü de Google Play için yolda. Tek seferlik ücretli, reklamsız ve internetsiz bir oyun; mağaza bağlantıları yayına girdiğinde Kalecik sayfasında olacak. Aynı "her şeyi koddan üret" yaklaşımının görüntü tarafını prosedürel taş sur yazısında anlattım.

Sık Sorulan Sorular

Oyun seslerini kodla üretmek için müzik bilgisi gerekir mi?

Efektler için temel ses fiziği yeterli: frekans, sönme süresi, filtre. Müzik için dizi, akor ve kadans gibi kavramları bilmek büyük fark yaratıyor; Kalecik'in bestecisi bu kuralların koda dökülmüş hâli. Kuralları siz koyduğunuzda rastgelelik yalnızca o kuralların içinde çeşitlilik üretir.

Kodla üretilen müzik telifsiz mi?

Kalecik'te hazır örnek, ses paketi ya da başkasına ait bir kayıt kullanılmadı; her ses bir Python fonksiyonunun çıktısı. Bu yüzden oyunda üçüncü taraf lisansı gerektiren bir ses yok.

LUFS nedir, tepe değeri neden yetmez?

LUFS, insan kulağının duyarlılığına göre ağırlıklandırılmış ortalama ses yüksekliğidir. Tepe değeri yalnızca en yüksek anı gösterir; tepe değerleri aynı olan iki parçadan biri çok daha yüksek duyulabilir. Parçaları birbirine eşitlemek için LUFS daha doğru bir ölçü.

Kalecik'te müzik kapatılabilir mi?

Evet. Ayarlarda müzik ve ses efektleri ayrı ayrı açılıp kapatılabiliyor. Müzik kendi bus'ında, efektler ve ortam sesleri ayrı bus'larda çaldığı için müziği kapatmak efektleri etkilemiyor.

Kalecik ne zaman ve hangi platformlarda çıkacak?

Kalecik iPhone ve iPad için; App Store sürümü çok yakında, Google Play sürümü de hazırlanıyor. Tek seferlik satın alınıyor, reklam ve abonelik yok, internetsiz çalışıyor. Güncel durum Kalecik sayfasında.

Yorumlar