Gece Müdürü, iPhone ve iPad için Türkçe hikâye oyunumuz. Oyuncu bir otelde üç gece vardiyası geçiriyor, müzik de hikâyenin duygusunu takip ediyor. İlk sürümde duygu değişince o an çalan player duruyor, sıradaki döngü sıfırdan başlıyordu. Bu yüzden her değişimde o döngünün açılış vuruşu yeniden çalıyordu. Modelimize göre aradaki geçişte seviye de çöküyordu: en fazla 4,26 dB.
O sürümü 12 Eylül 2026'da, oyun App Store incelemesine ilk kez gitmeden bir gün önce değiştirdik. Yani hiç yayımlanmadı. Artık üç döngü bir kez, ses aygıtının saatine göre aynı anda başlıyor ve duygu değişince yalnızca seviyeler değişiyor. Seviyeleri 90 satırlık bir Swift dosyasındaki değer tipi hesaplıyor, bu tip de ses çalmadan test edilebiliyor. Oyun Swift 6 ile yazıldı, iOS 18 ve sonrasında çalışıyor. Ses motoru yok: yağmur, efektler ve müzik AVAudioPlayer ile çalıyor.
Sonucu çevrimdışı bir modelle, komut satırından çalışan bir testle ve oyunun iOS simülatöründe bildirdiği durumlara bakarak kontrol ettik. Ekim 2026 itibarıyla gerçek bir iPhone'da dinleme testi yapmadık. Bu yüzden aşağıdaki sayılar yalnızca sinyal seviyesini gösteriyor, geçişlerin kulağa nasıl geldiğini değil.
Müzik: aynı grid'e oturan üç döngü
Müzik, bu oyun için kodla üretilmiş 16 saniyelik üç özgün döngüden oluşuyor: gizem, gerilim ve dayanışma (kodda mystery, tension, warmth). Hangisinin çalacağını oyuncunun seçimleri belirliyor: vicdan ağır basarsa dayanışma, çıkar ağır basarsa ya da otelin kaynaklarından biri azalırsa gerilim, arada kalırsa gizem.
Oyuncunun üç atmosferi tek tek dinleyebildiği ekran.
Melodi notaları iki saniyelik aynı grid'e oturuyor, bas 0. ve 8. saniyede vuruyor, pad'lerin ortak notaları var. Üç dosyayı tek bir müzik gibi kullanabilmemiz bu sayede. Alışılmış geçiş eğrisinin bu döngülerde yanlış sonuç vermesi de bu yüzden.
İlk sürümde duygu değişimi: döngü sıfırdan başlıyordu
İlk player'ı bir yapay zekâ agent'ı yazmıştı. Kodunu aynı gün öğleden sonra yine bir agent gözden geçirdi: kodu ve ses dosyalarını okudu, sesi çalmadı. Kod, duygu değişince o an çalan player'ı durduruyor, gain'i sıfırlıyor ve sıradaki döngüyü sıfırdan başlatıyordu. Geçiş doğrusaldı: 750 ms çıkış, 850 ms giriş.
Her döngü belirgin bir piyano ve bas vuruşuyla açılıyor: ilk 100 ms'si, son 100 ms'sinin 9 ila 11 dB üstünde. Bu klik değil, bestenin kendisi. Ama sıfırdan başlatılan döngü o vuruşu her değişimde tekrarlıyor. Çevrimdışı modelde eski geçişin ortasında 4,26 dB'e varan bir çökme de vardı.
Bu incelemeden sonra neyi test ettiğimiz de değişti. A döngüsünden B'ye bir kez geçmek pek bir şey kanıtlamıyor. Bozulan geçişler şunlar:
- ilk geçiş bitmeden A'dan B'ye, oradan yeniden A'ya dönmek
- geçişin ortasında müziği kapatıp açmak
- bir ses efekti çalarken geçiş yapmak
- arka plandan dönmek
Aynı cihaz saatine göre başlayan üç player
Üç player artık bir kez oluşturuluyor ve sonsuz döngüde çalıyor. Üçünü de play(atTime:) çağrısıyla aynı anda başlatıyoruz. Apple'ın belgelerine göre iki ya da daha fazla player'ı eş zamanlı başlatmanın yolu bu metot.
Başlangıç zamanını player'lardan birinin deviceCurrentTime değerinden, yani ses çıkış aygıtının saatinden okuyoruz. Bu zamanın gelecekte olması gerekiyor, 50 ms'lik pay bu yüzden. musicPlayers her duygu için bir AVAudioPlayer tutuyor, musicPosition ise müziğin son duraklatıldığı konum:
guard let clock = musicPlayers[.mystery] else { return }
let start = clock.deviceCurrentTime + 0.05
for mood in MusicMood.allCases {
guard let audio = musicPlayers[mood] else { continue }
audio.volume = 0
audio.currentTime = musicPosition.truncatingRemainder(dividingBy: audio.duration)
guard audio.play(atTime: start) else { throw CocoaError(.fileReadCorruptFile) }
}
O andan sonra duygu değişince hiçbir şey durmuyor, hiçbir player'ın konumu değişmiyor. Sessiz döngü sıfır seviyede çalmayı sürdürdüğü için gerektiğinde müziğin doğru yerinde oluyor.
Üç döngü de bir kez, aynı cihaz saatine göre başlıyor.
Baştan başlamayan, yön değiştiren geçişler
Seviyeler küçük bir değer tipinden, oyunun mixer'ı olan MusicTransition'dan geliyor. Bu tip, geçişin başladığı seviyeleri (from), gittiği seviyeleri (to), başlangıç zamanını ve süreyi tutuyor. retarget metodu sıfırdan yeni bir geçiş başlatmıyor. O anda duyulan seviyeleri yeni başlangıç noktası yapıyor. Böylece geçişin ortasında hedef değişirse karışım, dinleyenin o an duyduğu yerden devam ediyor.
Duygu değişimi 1,8 saniye, sessizlikten ilk giriş 0,65 saniye, müziği kapatmak 0,25 saniye sürüyor. Geçiş sürerken oyun, seviyeleri player'lara 20 ms'de bir setVolume(_:fadeDuration:) ile yazıyor.
Equal-power, ilişkili döngüler için yanlış eğri
Alışılmış geçiş eğrisi equal-power'dır (eşit güç): gain'lerin kareleri toplamı 1'de tutulursa seviye sabit kalır. Bu, birbiriyle ilgisi olmayan iki sinyal için doğru. Aynı oyundaki yağmurda durum bu: Cori Samuel'in kamu malı “Rain against the Window” kaydından kesilen 56 saniyelik döngünün ek yeri, beş saniyelik equal-power bir karışımla kapanıyor. Orada doğru eğri bu, çünkü 56 saniye arayla kaydedilmiş iki yağmur parçası arasında örnek düzeyinde bir ilişki yok.
Müzik döngüleri arasında ise ilişki var. Döngünün tamamında ölçülen korelasyon gizem ile gerilim arasında 0,365, gizem ile dayanışma arasında 0,143, gerilim ile dayanışma arasında 0,048. Kısa pencerelerde ise çok daha geniş bir aralıkta oynuyor: gizem ile gerilim için 250 ms'lik pencerelerde 0,028 ile 0,977 arasında. İki döngü neredeyse aynı notaları aynı fazda çaldığında genlikleri toplanıyor ve equal-power geçiş ortada kabarıyor. Düz doğrusal geçişin kusuru bunun tersi: eski geçişte olduğu gibi ortada çöküyor.
Korelasyonu hesaba katan eğri ve çevrimdışı karşılaştırma
Bu yüzden mixer, seviyeleri ölçülen korelasyonlarla normalize ediyor (yorumlarda “stem” diye geçenler bu döngüler):
func levels(at time: TimeInterval) -> [MusicMood: Double] {
let progress = duration > 0 ? min(1, max(0, (time - startedAt) / duration)) : 1
let eased = progress * progress * (3 - 2 * progress)
var mixed: [MusicMood: Double] = [:]
for mood in MusicMood.allCases {
mixed[mood] = from[mood, default: 0] * (1 - eased)
+ to[mood, default: 0] * eased
}
// Normalize for the notes shared by these three stems, avoiding both the
// linear-fade dip and the swell from treating correlated music as unrelated.
// The separate radius gives silence ↔ music its own smooth ramp.
let radius = magnitude(from) * (1 - eased) + magnitude(to) * eased
let length = magnitude(mixed)
guard length > 0 else { return [:] }
return mixed.mapValues { $0 * radius / length }
}
İkinci rampa, yani radius olmasaydı, sessizlikten başlayan bir geçiş daha ilk adımda tam seviyeye normalize edilirdi. Korelasyonlar büyüklük hesabına giriyor:
private func magnitude(_ levels: [MusicMood: Double]) -> Double {
let m = levels[.mystery, default: 0]
let t = levels[.tension, default: 0]
let w = levels[.warmth, default: 0]
// Whole-loop correlations measured from the bundled original PCM stems.
// If the music assets change, remeasure with scripts/analyze-audio.py.
return sqrt(max(0, m * m + t * t + w * w
+ 2 * (0.365168 * m * t + 0.142747 * m * w + 0.047811 * t * w)))
}
Eğriyi seçmeden önce adayları 1,8 saniyelik bir geçişin çevrimdışı modelinde karşılaştırdık: üç duygu arasında altı yön, döngü boyunca 128 başlangıç fazı ve geçişin içinde 65 konum. Tabloda o modelin, oyundaki üç döngü için 12 Eylül 2026'da hesapladığı en kötü değerler var. Her sayı, karışımın 100 ms'lik bir penceredeki seviyesini, iki döngünün o noktada tek başlarına verdiği seviyeye göre gösteriyor.
| Karışımın normalizasyonu | En büyük kabarma | En büyük çökme |
|---|---|---|
| Döngüler ilişkisizmiş gibi (equal-power) | 2,96 dB | 1,44 dB |
| Ölçülen korelasyonlarla (oyundaki) | 1,90 dB | 1,92 dB |
| Korelasyonlar ve her döngünün kendi seviyesiyle | 1,91 dB | 1,92 dB |
Korelasyonları hesaba katınca en büyük kabarma yaklaşık 1 dB azalıyor, en büyük çökme yaklaşık 0,5 dB artıyor. Her döngüyü ayrıca kendi seviyesiyle ağırlıklandırmak hiçbir sayıyı 0,1 dB bile değiştirmedi, bu yüzden onu dışarıda bıraktık. Bu türden hiçbir eğri her fazda düz olamaz, çünkü mixer her çift için tek bir sayı kullanıyor, kısa penceredeki korelasyon ise sürekli oynuyor.
Karışımı ses çalmadan test etmek
MusicTransition hiçbir ses API'sini kullanmıyor. Bir zaman alıyor, üç sayı döndürüyor. Bu sayede zor durumları komut satırından çalışan bir programla deneyebiliyoruz. mix bir MusicTransition, require ise koşul sağlanmazsa programı durduruyor:
mix.retarget(.tension, at: 2)
let beforeReversal = mix.levels(at: 2.3)
mix.retarget(.mystery, at: 2.3)
require(equal(beforeReversal, mix.levels(at: 2.3)),
"A-B-A reversal must preserve every currently audible level")
var now = 3.0
for index in 0..<300 {
now += 0.017 + Double(index % 9) * 0.011
let before = mix.levels(at: now)
let target: MusicMood? = index % 11 == 0 ? nil : MusicMood.allCases[index % 3]
mix.retarget(target, at: now, duration: target == nil ? 0.25 : 1.8)
require(equal(before, mix.levels(at: now)),
"Rapid retarget must be continuous, including off/on")
for value in mix.levels(at: now + 0.01).values {
require(value.isFinite && value >= 0 && value <= 1.000001,
"Bounded nonnegative gain")
}
}
Aynı program üç özelliği daha kontrol ediyor:
- Zaten çalan duygu yeniden istenirse geçiş baştan başlamamalı.
- Müzik kapatılınca seviye tam sessizliğe inmeli.
- Kısma (ducking), yani bir ses efektinin altında müziğin kısa süre kısılması, o anki seviyeden başlamalı ve ikinci bir efektin araya girdiği yerden toparlanmalı.
Seviye ve kısma için kullanılan rampa dâhil, mixer'ın tamamı yalnızca Foundation'ı import eden 90 satırlık tek bir dosya. Dosyayı kontrol programıyla birlikte derleyip çalıştırmak Mac'te yaklaşık bir saniye sürüyor ve kontrollerin hepsi geçiyor. Bulmaca oyunumuzun kurallarını da aynı yolla, her checkpoint'te her eylemi deneyen bir kontrolle test ediyoruz.
Bu program, bozulan geçişler diye saydığımız dört durumdan ilk ikisini kapsıyor. Üçüncüsünde yalnızca kısma rampasını tek başına deniyor: duygu geçişiyle kısmayı aynı anda denemiyor, çünkü ikisini mixer'dan sonra, player'da çarpıyoruz. Arka plandan dönüş için ise gerçek player'lar gerekiyor. İki yolun da kodunu okuduk, ikisinin de testi yok.
Ölçümlerin göstermedikleri
Tablodaki geçişler AVAudioPlayer kayıtları değil, ses dosyalarına uygulanan gain'in matematiksel modelleri. setVolume(_:fadeDuration:) çağrısının gerçekte izlediği eğriyi de bir cihazın çıkışını da kaydetmedik. Seviyeler RMS, yani ağırlıksız bir ölçü. İnsanın duyduğu gürlüğü modelleyen ölçü LUFS olurdu. Ayrıca yalnızca müziği modelledik, yağmuru ve efektleri katmadık.
Analizi bir yapay zekâ agent'ı yaptı: ses örneklerini okudu, tek bir ses bile çalmadı. Simülatörde yalnızca durumları doğruladık, örneğin müzik kapatılıp açıldıktan sonra oyunun çalan duygu olarak hangisini bildirdiğini. Sinyal ölçümü dinleme testi değildir. Yukarıdaki sayıların hiçbirini de öyle sunmuyoruz.
AVAudioPlayer ile adaptif müzik için kontrol listesi
- Bütün döngüleri bir kez, aynı cihaz saatine göre başlatın. Duygu değişsin diye hiçbirini durdurmayın.
- Geçişi o an duyulan seviyelerden yeni hedefe yöneltin. Sıfırdan yeniden başlatmayın.
- Geçiş eğrisini seçmeden önce döngülerin birbiriyle ne kadar ilişkili olduğunu ölçün. Equal-power ilişkisiz sinyaller içindir.
- Karışımı, zamanı parametre olarak alan bir değer tipine koyun ve bozulan geçişleri test edin.
- Ölçümün neyi göstermediğini ölçümün yanına yazın.