#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:
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):
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:
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.
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
Curious Fishing
An aquatic puzzle game
Status | Released |
Author | RhythmLynx |
Genre | Puzzle |
Tags | blocks, chiptune, fantasy-console, Fishing, PICO-8, Pixel Art, Retro, Sokoban, underwater |
Languages | Arabic, German, English, Spanish; Castilian, French, Italian, Japanese, Korean, Portuguese (Brazil), Russian, Chinese, Chinese (Simplified), Chinese (Traditional) |
Accessibility | Configurable controls |
More posts
- #15: Why development took so longNov 28, 2022
- #14: Devlog updates and launch statsNov 28, 2022
- #13: Release!Nov 28, 2022
- #12: Localizing a low resolution pixel art gameNov 28, 2022
- #11: OrganizationNov 27, 2022
- #09: Interview with sound designer Andrew DoddsNov 27, 2022
- #08: (Work)flow: artNov 27, 2022
- #07: Design iteration: player movementNov 25, 2022
- #06: UndoNov 25, 2022
Leave a comment
Log in with itch.io to leave a comment.