#10: Audio tech


This post was originally written in May 2020. At the time the game was still called Hook, Line and Thinker

The previous devlog was about 1 audiophile. This devlog is about 23 audio files.

At the time of writing there are 18 sound effects in the game:

  • break_debris
  • collect_fish
  • crab_snip
  • cut_stingers
  • drop_fish
  • grab_fish
  • hook_move
  • invalid_move
  • kill_fish
  • level_restart
  • level_start
  • level_win
  • menu_move
  • menu_select
  • reel_in
  • sponge_absorb
  • squid_ink
  • undo

We may need a few more but probably not many. All of these are between 0.1 to 0.2 seconds except level_restart at 0.4s and level_win at a lavish 1 second. Each sound effect file is between 5KB to 7KB except level_win at 12KB.

There are 5 pieces of music:

  • music_menu: 1m 54s, 888KB
  • music_game1: 1m 3s, 527KB
  • music_game2: 1m 10s, 576KB
  • music_boss: 1m 6s, 554KB
  • music_credits: 1m 24s, 730KB (work in progress)

As you might expect music_menu plays during most menus including the title screen, level select, aquarium and settings screen; the two exceptions are the about screen where music_credits plays instead and the pause menu which doesn’t interrupt the gameplay music. Speaking of gameplay music, every time you enter a level the music alternates between music_game1 and music_game2 except of course the boss levels which play music_boss.

Originally all of the audio was stored as .wav files. Since the number of other assets is pretty low and the textures are all incredibly low resolution pixel art, when I bothered to generate and check a build report I discovered that audio accounted for literally 99% of the build size:

Before

Changing from .wav to .ogg dramatically reduced file sizes without a noticeable drop in audio quality (helped by the retro nature of the sounds). Audio now accounts for a slightly more modest 87% of build size (the 5 music tracks account for 85% by themselves):

After

This dropped my Android release build from ~60MB to ~8.5MB! Turns out build reports are pretty useful, thanks Defold. Overall the content of my project amounts to ~3.8MB of the build, with audio being ~3.3MB of that:

Build structure

Implementation: sound effects

I’ve said this before but one of the pleasures of this project is not having to care at all about loading and unloading assets or general memory management (so long as we don’t leak, we’re good). All audio and textures are just kept in memory at all times. In the bootstrap main.collection there is an audio game object with an audio.script component and 23 sound components - one for each audio file.

Audio object

All the sound effects belong to the ‘sfx’ group and do not loop. Triggering a sound effect is as simple as sending a message to the audio game object:

msg.post("main:/audio", "play_sfx", { sound = "break_debris" })

This message is then handled in the audio.script on_message function:

if message_id == hash("play_sfx") then
	if not self.played_this_tick[message.sound]
	and (message.sound ~= "reel_in" or self.reel_timeout <= 0) then
		msg.post("#sfx_"..message.sound, "play_sound")
		self.played_this_tick[message.sound] = true
		if message.sound == "reel_in" then
			self.reel_timeout = MAX_REEL_TIMEOUT
		end
	end
end

When the hook was the only thing that could trigger sound effects it wasn’t possible to have the same sound triggered multiple times in one frame. When I added flowing water I encountered an issue where for example two flows would push two crabs into two debris on the same tick, causing the break_debris sound to be triggered twice in the same frame and play much louder than usual. Now the self.played_this_tick table prevents sounds that have already fired from firing again and is reset each frame in the update loop.

There is also a timeout applied specifically to the reel-in sound effect as it’s the only one which can fire rapidly enough to become monotonous. A comparison without and with the timeout:

Implementation: music

The only difference for music sound components is that the group is set to ‘music’ and looping is ticked. Triggering a music change is done through a similar message:

msg.post("main:/audio", "crossfade", { music = "music_boss" })

The handling of this message is slightly more complex though. Sound effects are ‘fire and forget’ but music is continuous - we don’t want to play multiple songs at once nor do we want to immediately cut from one song to another; we want to fade the current song out then fade the next song in. This can be heard in the video above showing the reel-in sound effect timeout, when the music transitions from music_menu to music_game1 after the level selection menu.

if message_id == hash("crossfade") then
	if self.current_music ~= message.music then
		self.crossfading = true
		self.crossfade_target = message.music
		self.crossfade_time = 0
	end
end

During the update loop we first fade out the original song, then when the transition can’t be heard we cut it and start the new song, then fade that up to full volume. Fading is achieved by linearly interpolating the music group gain from 1 to 0 or from 0 to 1.

if self.crossfading then
	if self.crossfade_time < HALF_CROSSFADE_LENGTH then
		-- during the first half we're fading out the original music
		self.crossfade_time = self.crossfade_time + dt
		if self.crossfade_time >= HALF_CROSSFADE_LENGTH then
			msg.post("#"..self.current_music, "stop_sound")
			self.current_music = self.crossfade_target
			self.crossfade_target = nil
			msg.post("#"..self.current_music, "play_sound")
		end
	else
		-- during the second half we're fading in the new music
		self.crossfade_time = self.crossfade_time + dt
		if self.crossfade_time >= CROSSFADE_LENGTH then
			self.crossfade_time = CROSSFADE_LENGTH
			self.crossfading = false
		end
	end
	local start_gain, ratio = 1, self.crossfade_time / HALF_CROSSFADE_LENGTH
	if self.crossfade_time >= HALF_CROSSFADE_LENGTH then
		start_gain, ratio = 0, (self.crossfade_time - HALF_CROSSFADE_LENGTH) / HALF_CROSSFADE_LENGTH
	end
	local end_gain = 1 - start_gain
	local gain = vmath.lerp(ratio, start_gain, end_gain)
	sound.set_group_gain("music", gain * g_save_data.music_volume / MAX_VOLUME)
end

I guess technically this isn’t a crossfade, since we’re not actually playing both songs at once. For a true crossfade you’d want to be altering the gains on the sound components themselves rather than on the group; since they both belong to the ‘music’ group they can’t be faded independently using my approach.

Implementation: volume sliders

In the settings screen there are separate volume sliders for sound effects and music, these are just directly setting the gain on the ‘sfx’ and ‘music’ groups. The only gotcha here was that in right-to-left languages such as Arabic (which we support) the sliders need to be mirrored so that they run from right to left (moving the slider to the left increases the volume).

Code

Here is the entire audio.script file in the hopes that someone finds it useful. I wrote most of this code over 2 years ago but I don’t think there’s been any major audio changes for Defold since then.

MAX_VOLUME = 10
local HALF_CROSSFADE_LENGTH = 0.6
local CROSSFADE_LENGTH = 2 * HALF_CROSSFADE_LENGTH
local MAX_REEL_TIMEOUT = 0.2

function set_music_volume(volume)
	if volume < 0 then volume = 0 end
	if volume > MAX_VOLUME then volume = MAX_VOLUME end
	g_save_data.music_volume = volume
	sound.set_group_gain("music", g_save_data.music_volume / MAX_VOLUME)
end

function set_sfx_volume(volume)
	if volume < 0 then volume = 0 end
	if volume > MAX_VOLUME then volume = MAX_VOLUME end
	g_save_data.sfx_volume = volume
	sound.set_group_gain("sfx", g_save_data.sfx_volume / MAX_VOLUME)
end

function init(self)
	sound.set_group_gain("master", 1)

	-- TODO: fade music in during startup, don't abruptly play
	self.current_music = "music_menu"
	msg.post("#"..self.current_music, "play_sound")
	
	self.crossfading = false
	self.crossfade_time = 0
	self.crossfade_target = nil
	
	self.played_this_tick = {}
	self.reel_timeout = 0
end

function update(self, dt)
	if self.crossfading then
		if self.crossfade_time < HALF_CROSSFADE_LENGTH then
			-- during the first half we're fading out the original music
			self.crossfade_time = self.crossfade_time + dt
			if self.crossfade_time >= HALF_CROSSFADE_LENGTH then
				msg.post("#"..self.current_music, "stop_sound")
				self.current_music = self.crossfade_target
				self.crossfade_target = nil
				msg.post("#"..self.current_music, "play_sound")
			end
		else
			-- during the second half we're fading in the new music
			self.crossfade_time = self.crossfade_time + dt
			if self.crossfade_time >= CROSSFADE_LENGTH then
				self.crossfade_time = CROSSFADE_LENGTH
				self.crossfading = false
			end
		end
		local start_gain, ratio = 1, self.crossfade_time / HALF_CROSSFADE_LENGTH
		if self.crossfade_time >= HALF_CROSSFADE_LENGTH then
			start_gain, ratio = 0, (self.crossfade_time - HALF_CROSSFADE_LENGTH) / HALF_CROSSFADE_LENGTH
		end
		local end_gain = 1 - start_gain
		local gain = vmath.lerp(ratio, start_gain, end_gain)
		sound.set_group_gain("music", gain * g_save_data.music_volume / MAX_VOLUME)
	end
	
	for k,v in pairs(self.played_this_tick) do
		self.played_this_tick[k] = false
	end
	self.reel_timeout = math.max(0, self.reel_timeout - dt)
end

function on_message(self, message_id, message, sender)
	if message_id == hash("crossfade") then
		if self.current_music ~= message.music then
			self.crossfading = true
			self.crossfade_target = message.music
			self.crossfade_time = 0
		end
	
	elseif message_id == hash("play_sfx") then
		if not self.played_this_tick[message.sound]
		and (message.sound ~= "reel_in" or self.reel_timeout <= 0) then
			msg.post("#sfx_"..message.sound, "play_sound")
			self.played_this_tick[message.sound] = true
			if message.sound == "reel_in" then
				self.reel_timeout = MAX_REEL_TIMEOUT
			end
		end
	end
end

If you want to use and extend this code for your own projects please feel free! Here are a couple of changes I would recommend:

  • For crossfading music, manipulate the gain on the sound components individually instead of their shared group gain so that a true crossfade is possible rather than fading completely out then in.
  • This code doesn’t handle the case of requesting a music transition while a crossfade is already in progress; this isn’t possible in Hook and I’ll never finish the game if I spend time solving problems I don’t have. You would want to start fading out the currently playing music from whatever volume it was at when the new request arrived.
  • The global volume setting functions should probably be replaced with a ‘set_volume’ message. These functions are called when save data is loaded during startup and when the volume sliders are changed in the settings screen, which is in a different collection. I didn’t really understand Defold’s message passing architecture when I was writing the save data system so I used global functions instead as I ran into problems trying to send messages between objects in different collections, some of which are loaded through proxies. There’s probably a cleaner solution but this works fine for me.

You can follow the game’s sound designer Andrew Dodds on Twitter @doddsy91

Follow me on itch or Twitter to see what I work on next

Leave a comment

Log in with itch.io to leave a comment.