Gece Müdürü is our Turkish-language narrative game for iPhone and iPad, set over three night shifts at a hotel. Its music follows the mood of the story. In the first version a change of mood stopped the player that was sounding and started the next loop from zero. Every change repeated that loop's opening downbeat, and by our model the fade in between dipped by up to 4.26 dB.
We replaced that version on 12 September 2026, the day before the game first went to App Review, so it never shipped. All three loops now start once, at the same instant on the audio device's clock, and a mood change only moves volumes. The volumes come from a Swift value type, in a 90-line file, that can be tested without playing a sound. The game is Swift 6 for iOS 18 and later, with no audio engine: rain, effects and music all play through AVAudioPlayer.
We checked the result in an offline model, with a command-line test and by the states the game reported in the iOS Simulator. As of October 2026 we have not run a listening test on a physical iPhone, so the figures below are signal levels, not a judgement of how the transitions sound.
The music: three loops on one grid
The music is three original 16-second loops generated by code for this game: mystery, tension and warmth. Which one plays follows the player's choices: warmth for conscience, tension for self-interest or a hotel resource running low, mystery in between.
The sheet where a player can listen to each of the three moods. Gizem is mystery, Gerilim tension, Dayanışma warmth.
Melody notes start on the same two-second grid, the bass lands at 0 and 8 seconds, and the pads share pitches. That lets three files work as one score, and it is why the usual crossfade goes wrong with them.
What the first version did on a mood change
An AI agent wrote the first player, and an agent reviewed that code the same afternoon, reading the code and the audio files without playing them. On a mood change it stopped the outgoing player, reset the gain and started the next loop from zero, with a linear fade of 750 ms out and 850 ms in.
Each loop opens with a clear piano and bass hit: its first 100 ms are 9 to 11 dB above its last 100 ms. That is the composition and not a click, but a loop started from zero repeats the hit at every change. In an offline model the old fade also dipped by as much as 4.26 dB in the middle.
The review changed what we test as well. One switch from loop A to loop B proves little. The transitions that break are these:
- A to B and back to A before the first fade has finished;
- music switched off and on in the middle of a fade;
- a fade while a sound effect is playing;
- the return from the background.
Three players started on one device clock
The three players are now created once and loop endlessly. They start together through play(atTime:), which Apple documents as the way to synchronize two or more players. The start time is read from one player's deviceCurrentTime, the clock of the audio output device, and has to lie in the future, hence the 50 ms. musicPlayers holds one AVAudioPlayer per mood, and musicPosition is where the music stood when it was last paused:
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) }
}
From then on a mood change stops nothing and seeks nothing. A silent loop keeps running at volume zero, so it is at the right bar whenever it is needed.
All three loops start once, on one device clock.
Fades that retarget instead of restarting
The volumes come from a small value type, MusicTransition, the game's mixer. It stores the levels a fade started from (from), the levels it is heading for (to), a start time and a duration. Its retarget method does not begin a fresh fade from zero. It makes the levels that are audible at that instant the new starting point, so a change of mind halfway through a fade continues from what the listener is hearing.
A mood change takes 1.8 seconds, the first entry from silence 0.65 and switching off 0.25. While a fade runs, the game writes the levels to the players every 20 ms with setVolume(_:fadeDuration:).
Why equal power is the wrong curve for related loops
The usual crossfade is equal power: keep the squared gains adding up to one and the level stays steady. That holds for two unrelated signals. The rain in the same game is such a case: its 56-second loop, cut from Cori Samuel's public-domain recording “Rain against the Window”, is closed with a five-second equal-power blend. That is the right curve there, because two stretches of rain recorded 56 seconds apart have no sample-level relation.
The music loops are related. Measured over the whole loop, the correlation is 0.365 between mystery and tension, 0.143 between mystery and warmth and 0.048 between tension and warmth. Over short windows it swings much further: for mystery and tension, 250 ms windows range from 0.028 to 0.977. Where two loops play nearly the same notes in phase, their amplitudes add up, and an equal-power fade swells in the middle. A plain linear fade has the opposite fault and dips, as the old one did.
The correlation-aware curve and the offline comparison
So the mixer normalizes with the measured correlations (the “stems” in the comments are the loops):
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 }
}
Without the second ramp, radius, a fade from silence would be normalized to full level at its first step. The correlations enter in the magnitude:
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)))
}
Before choosing, we compared the candidates in an offline model of a 1.8-second fade: six directions between the three moods, 128 start phases across the loop and 65 positions inside the fade. The table holds that model's worst cases for the game's three loops, computed on 12 September 2026: the level of the mix in a 100 ms window, relative to the two loops on their own at that point.
| Normalization of the mix | Largest swell | Largest dip |
|---|---|---|
| As if the loops were unrelated (equal power) | 2.96 dB | 1.44 dB |
| With the measured correlations (in the game) | 1.90 dB | 1.92 dB |
| Correlations, and each loop weighted by its own level | 1.91 dB | 1.92 dB |
Accounting for the correlations takes about 1 dB off the worst swell and adds about 0.5 dB to the worst dip. Weighting each loop by its own level as well moved no figure by as much as 0.1 dB, so we left it out. No curve of this kind can be flat at every phase, because the mixer uses one number per pair and the short-window correlation keeps moving.
Testing the mix without playing a sound
MusicTransition never touches an audio API. It takes a time and returns three numbers, so a command-line program can run the hard cases. mix is a MusicTransition, and require stops the program when a condition is false:
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")
}
}
The same program checks three more properties:
- a repeated request for the current mood must not restart a fade;
- switching off must reach silence;
- ducking, the short dip in the music under a sound effect, must begin at the current level and recover from wherever a second effect interrupts it.
The mixer, including the ramp for volume and ducking, is one 90-line file that imports only Foundation. Compiling and running it with the check takes about a second on a Mac, and the check passes. We test the rules of our puzzle game the same way, with a check that tries every action at every checkpoint.
That covers the first two transitions listed earlier and, for the third, the ducking ramp on its own. The program never runs a mood fade and a duck together: the player multiplies the two after the mixer has answered. The return from the background needs the real players. We have read both paths in the code and have no test for either.
What the measurements do not show
The fades in the table are mathematical models of gain applied to the audio files, not recordings of AVAudioPlayer. We did not capture the curve that setVolume(_:fadeDuration:) really follows, or the output of a device. The levels are RMS, an unweighted measure, where LUFS would model loudness as people hear it, and we modelled the music alone, without rain and effects.
The analysis was run by an AI agent that reads samples and never played a sound. In the Simulator we confirmed only states, such as which mood the game reported as playing after a switch off and on. A signal measurement is not a listening test, and none of the numbers above is offered as one.
A checklist for adaptive music with AVAudioPlayer
- Start every loop once, on one device clock, and never stop one to change the mood.
- Retarget a fade from the levels audible at that moment; never restart it from zero.
- Measure how related your loops are before choosing a crossfade curve. Equal power is for unrelated signals.
- Put the mix in a value type that takes time as an argument, and test the transitions that break.
- Write down what a measurement does not show, next to the measurement.