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

Royalty-Free Game Music: Generating Every Sound in Code

Ahmet Balaman

11 min read

Vibe CodingKalecikGodotPythonSound SynthesisClaude
Royalty-Free Game Music: Generating Every Sound in Code

Nothing you hear in Kalecik comes from a recording. The soft "tock" of a stone settling onto another, the distant birds, the crickets at night, water lapping at a pond's edge, the sheep, and every note of the background music were generated from numbers inside a Python script. No sound packs, no samples, no licenses. Kalecik is a calm castle and village builder I've been making for iPhone and iPad, and this post is about how its sound was made.

First, the honest part: the code was written by Claude Code agents. I listened on my phone, said what bothered me and decided what stayed. The twist is that the agent writing the sounds couldn't hear any of them, so part of this post is about making audio without ears. The whole project is covered in the main post of this series.

Why synthesis instead of recordings?

Recordings come with two questions: what's the license, and does it fit the game's mood? Synthesis makes both go away. The first line of tools/make_audio.py sums it up: "Synthesise every sound the game uses (no samples, no licences)." The script uses nothing but numpy and calls lame for MP3 encoding. Everything runs at 22,050 Hz in mono, built from additive and modal synthesis, FFT filtering and an FFT convolution reverb.

Kalecik’s village in evening light

Evening: the music plays from this hour’s deck too.

The stone sound is the easiest place to see modal synthesis at work. A struck object vibrates at a handful of frequencies, each dying away at its own rate. Pick those frequencies and decay times, and you've picked the object:

def modal(freqs, decays, amps, seconds):
    """Struck object: a few exponentially decaying sine modes."""
    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):
    """A stone settling onto another: a soft 'tock' with a little grit."""
    r = np.random.default_rng(seed)
    s = r.uniform(0.85, 1.15)                      # each variant is a slightly different stone
    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       # the moment of impact
    y += fft_filter(rng.normal(0, 1, len(y)), lo=600, hi=3500) * np.exp(-tt(0.4) / 0.06) * 0.08   # grit
    return outdoor(fft_filter(y, hi=4500), 0.12)

Most sounds take a seed. Change the seed and the stone changes size, so variants like thud, thud_2 and thud_3 come for free. The game picks a random variant each time, but never the same one twice in a row.

Birds, crickets and water

The nature sounds are built from the same small parts. A bird call is a sine wave whose frequency sweeps quickly (a chirp); string a few rising chirps together and you get a tweet, pair up falling ones and you get a different species. A cricket is a steady repeat of 14 ms pulses at around 4 kHz. Water lapping at the bank is band-passed noise plus a few bubbles whose pitch slides upward. Everything finally passes through outdoor(), a convolution with a short impulse response made of exponentially decaying noise, which places the sound outdoors and a little farther away.

The sheep, take two: formants

The first sheep was bad. My message after hearing it on the phone, roughly translated from Turkish, was: "baa.wav and baa_2.wav sound terrible, nothing like a sheep. Redo them so they're not annoying."

For the second try, the agent turned to the source-filter model used for human speech. The larynx produces a regular train of pulses, and their harmonics pass through the resonances of the mouth and nasal cavity, called formants. Where those resonances sit determines which vowel we hear. For the sheep's "baa," it used three formants at roughly 700, 2,100 and 2,900 Hz, a nasal "m" murmur at the start, and a 7-9 Hz tremble (shortened):

def baa(seed):
    """A sheep a field away: a nasal 'm' opening into a trembling 'eh-eh-eh'."""
    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 tremble
    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:                                        # glottal source: a harmonic series
        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                         # the opening 'm'
    open_ = np.clip((t - 0.05) / 0.08, 0, 1)                     # m -> eh
    trem = 1 - 0.45 * (0.5 + 0.5 * np.sin(2 * np.pi * rate * t + np.pi))
    y = (nasal * (1 - open_) + vowel * open_) * trem
    # ... the real code adds breath, an envelope and a band-pass filter
    return outdoor(y, 0.35)

Now a sheep bleats every 70 to 160 seconds during the day, quiet and far off. Whether it sounded good enough was my call, not the agent's; all the agent had to go on was numbers.

Nothing loops

In the first version, the wind was a loop. My feedback from the phone was: "Something's wrong with the background sound. There's a sea sound that keeps getting stuck. Is there a sea in this game?" A continuous synthesized wind noise sounded like surf, and the point where the loop wrapped around was most likely what felt like it getting stuck.

Kalecik’s music: four shuffled decks for four times of day, 30-110 seconds of silence between pieces, no loops

The fix became a rule: nothing in the game loops. The comment at the top of audio.gd says it plainly: "Nothing loops, so there is no loop seam to hear." Ambience is made of randomly timed one-shots, and in each mood every sound has its own range of gaps:

## Ambience per mood: [sound, min gap s, max gap s, dB, pitch variation]
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]],   # day
	[["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]], # evening
	[["cricket", 2.0, 4.0, -27.0, 0.05], ["owl", 45.0, 100.0, -24.0, 0.04]],                                        # night
	[["bird", 30.0, 60.0, -27.0, 0.05]],                                                                             # winter
]

Each time a sound plays, its pitch is nudged slightly and its next time is drawn again from the range. The leaf rustle deliberately has no low end; the comment in the code says "so it never reads as surf." When the camera looks at a pond, lapping water joins in, and in winter it goes quiet because the ponds freeze. Even rain doesn't loop: 7-second layers with soft fades at both ends start every 5.5 seconds and overlap. When it rains, the birds and the sheep mostly go quiet too.

Music follows the same logic. For each mood (day, evening, night, winter), pieces are dealt from a shuffled deck, so a piece only comes back after all the others have played:

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:   # never the same piece twice running
			deck.push_front(deck.pop_back())
	return deck.pop_back() if not deck.is_empty() else ""

Between two pieces there's a stretch of silence: 30 to 75 seconds during the day, 45 to 110 seconds at night and in winter. Sound runs on three separate buses, Music, SFX and Ambience, and the music and sound effect switches in the settings simply mute those buses.

A generative composer: 37 pieces, about 65 minutes

The first soundtrack was six hand-written pieces: morning, meadow, waltz, sunset, night and snow. Then I gave the agent a goal, roughly translated: "Make music you could listen to for two hours without getting bored or feeling smothered."

Kalecik in winter: the village under snow

Winter mood: quiet pieces led by bells and harp.

Its answer was tools/music_gen.py, a rule-based composer. Every piece is written from a seed, and the seed picks the key, mode, tempo, meter, chord progression, instruments and a motif-based melody:

class Composer:
    def __init__(self, mood, seed):
        self.cfg = MOODS[mood]
        self.r = np.random.default_rng(seed)      # every decision in the piece comes from this seed
        r, c = self.r, self.cfg
        self.mode = str(r.choice(c["modes"]))     # major, lydian, 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 or 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]   # the melody changes instrument on repeat
        # ... chord progressions and accompaniment come from the same seed

Melodies follow strict rules so they stay singable, like folk tunes. The basic unit is an 8-bar period: the first four bars rise like a question and pause on a half cadence, and the last four recall the motif and close on the tonic. Notes always stay in the scale, strong beats land on chord tones, the melody moves mostly by step, a leap is followed by a step back, and it stays out of the high register. The form depends on the mood; a daytime piece runs intro, A, ornamented A', B, a breathing space, A'' and an outro.

Mood Mode Tempo Lead instruments
Day major, Lydian, Mixolydian 70-90 BPM flute, piano, harp
Evening major, Lydian (seventh chords) 58-70 BPM piano, flute
Night Dorian, Aeolian 50-60 BPM celesta, music box, piano
Winter major, Lydian 54-64 BPM glockenspiel, celesta, flute

The instruments are synthesized too. The flute is a nearly pure tone with delayed vibrato and band-limited breath; the piano is two slightly detuned strings; harp and guitar are plucked strings whose upper harmonics die faster. The result is 37 pieces, 6 written by hand and 31 by the composer (14 day, 9 evening, 7 night, 7 winter), about 65 minutes in total and 19 MB of MP3. So there aren't two hours of unique music. But with long silences between pieces and a freshly shuffled order every round, a long session never hears the same sequence twice.

Why the music kept cutting out: a token and a watchdog

In the same message I'd reported a bug: "After a while the music suddenly cuts out." The agent found the cause by reading the code. When the mood changed, the current piece faded out over 3 seconds, and when the fade finished it called stop(). If the piece happened to end on its own during those 3 seconds, a new piece started, and the old fade's stop() killed the new piece mid-note. The fix was a token: every new piece increments a counter, and a stale fade leaves everything alone if the token has changed:

var token := _token
_fade = create_tween()
_fade.tween_property(_music, "volume_db", -50.0, 3.0)
_fade.tween_callback(func():
	if token == _token:   # a newer piece may have started meanwhile: leave it alone
		_music.stop())

A watchdog was added as a second safety net. On iOS, a phone call, Siri or the app going to the background can stop audio. Every frame, the watchdog checks: if the piece seems to be playing but its position hasn't moved for 2 seconds, or if it stopped before it was over, it picks the piece up where it left off with a short fade-in:

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:          # playing but the clock stands still: kick it
		_stall = 0.0
		_resume()
elif _last_pos < _piece_len - 1.5:
	_resume()                  # stopped although the piece wasn't over

There's also a lesson here about agents working in parallel. An outside GPT review I pasted in caught that the music files and the code were out of sync at one point: while the new pieces were being generated, the old MP3s had been deleted before the code that looked for them was updated. After that, the order was always the same: new files first, then the code, and only then delete the old ones.

Making sound without ears: LUFS and spectrograms

This was the strangest constraint of the project: the agent writing the sounds couldn't hear them. It relied on two tools to check its work. The first was loudness measurement. LUFS, a loudness unit weighted to match how sensitive human hearing is, was measured for every piece with ffmpeg's ebur128 filter, which also reports the peak. The second was spectrograms: ffmpeg drew a frequency-over-time picture of each piece as a PNG, and the agent looked at those images to judge where the highs and the noise sat:

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

The composer brings each piece to a target RMS level for its mood, then runs it through a gentle tanh limiter. The hand-written pieces came out a little hot, so a per-mood gain brought them in line. The measured result: about -22.5 LUFS by day, -23.3 in the evening and -24 at night and in winter. In the first round of measurements, the flute's breath noise was also narrowed to a tighter band and turned down.

Still, the ears have the final say. The numbers tell you the pieces are consistent with each other, not whether they're any good, so I listened to every audio change on the phone. A small aside: when sound coming from the Mac during test runs started bothering me while I was working on other things, all automated tests switched to Godot's --audio-driver Dummy option and ran silently.

Silent mode

The last issue wasn't technical; it was a decision. With the phone in silent mode, the game made no sound at all. I wanted the opposite and asked, roughly: "Can we have sound even when the phone is on silent?" On iOS, that's decided by the app's audio session category, and in Godot it's a two-line setting in project.godot:

[audio]

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

3 is the "Playback" category, which plays sound even in silent mode. mix_with_others lets the game mix with audio from another app instead of interrupting it. It's a debatable choice, since many games respect silent mode. In Kalecik the counterweight is in the settings, where music and sound effects can be turned off separately.

Kalecik is in development for iPhone and iPad. Version 1.0 is being prepared for App Store review, and an Android version for Google Play is on the way. It's a one-time purchase with no ads, and it works fully offline; the store links will be on the Kalecik page once they're live. The same everything-from-code approach on the visual side is covered in the post on procedural stone walls, gates and bridges.

Frequently Asked Questions

Do you need to know music theory to generate game audio in code?

For sound effects, basic acoustics is enough: frequency, decay time, filters. For music, knowing concepts like scales, chords and cadences makes a big difference; Kalecik's composer is essentially those rules written down as code. When you set the rules, randomness only adds variety inside them.

Is music generated in code royalty-free?

Kalecik uses no samples, sound packs or anyone else's recordings; every sound is the output of a Python function. That means there's no sound in the game that needs a third-party license.

What is LUFS, and why isn't peak level enough?

LUFS measures average loudness weighted to match the sensitivity of human hearing. Peak level only shows the single loudest moment, so two pieces with the same peak can sound very different in loudness. To match pieces to each other, LUFS is the better measure.

Can you turn off the music in Kalecik?

Yes. Music and sound effects can each be turned on or off in the settings. Music plays on its own bus, with effects and ambience on separate ones, so turning the music off doesn't affect the effects.

When is Kalecik coming out, and on which platforms?

Kalecik is for iPhone and iPad. The App Store version is coming soon, and a Google Play version is in the works. It's a one-time purchase with no ads or subscriptions, and it plays offline. The current status is on the Kalecik page.

Comments