singe/docs/lessons/10-sound.adoc
2026-09-22 21:57:42 -05:00

589 lines
21 KiB
Text

== Lesson 10: Sound
image::learn/10-sound.png[The finished lesson, 480]
A game with no sound feels broken in a way that is hard to put a finger on.
The shots go out and nothing happens. The rocks break and nothing happens. Put
a half second noise on each of those and the same game suddenly feels like it
is made of something.
This lesson gives your game a shot, an explosion, and music underneath. On the
way it introduces your third callback: the one the engine uses to tell you
that a sound has finished playing.
=== One Noise
The art kit you copied in lesson nine has two sounds in it, `shoot.wav` and
`boom.wav`, and the explosion is the easier of the two to hear. Make a new
file beside your `art` folder and type this in.
[source,lua]
----
dofile("Singe/Framework.singe")
local boom = soundLoad(DIR .. "art/boom.wav")
function onInputPressed(what)
if what == SWITCH_BUTTON1 then
soundPlay(boom)
end
end
function onOverlayUpdate()
overlayClear()
overlayPrint(2, 2, "Press space.")
return OVERLAY_UPDATED
end
function onShutdown()
soundUnload(boom)
end
----
Run it and tap the space bar three or four times quickly. The bangs pile up on
top of each other instead of cutting each other off.
Holding the space bar down gives you exactly one bang, not a stream of them.
That is the same behaviour you met in lesson three: a held key is one press,
reported once, and the engine throws away the repeats your keyboard sends. It
is why steering needs `onInputReleased` and a variable rather than counting
presses.
=== What Just Happened
The shape is the same as lesson nine's: load once at the top, use the handle,
give it back at the end.
[source,lua]
----
local boom = soundLoad(DIR .. "art/boom.wav")
----
`soundLoad` reads an audio file and keeps it in memory, and hands back a
handle -- the same idea as a sprite handle, a number that means "that clip" to
the engine. `DIR` is there for the same reason it was there in lesson nine:
the sound lives in your game's folder, and the game has to be able to find it
from wherever Singe was started.
Everything lesson nine said about loading applies here word for word. Load at
the top of the file, once. A `soundLoad` inside `onOverlayUpdate` loads the
clip sixty times a second and never gives one back, and the game dies of it
some minutes later.
[source,lua]
----
soundPlay(boom)
----
Play it. That is the whole of playing a sound effect, and the engine takes
care of the rest: the clip starts, it finishes, and it never gets in the way
of your program.
But `soundPlay` hands something back too, and it is *not* the same number you
gave it. Change the line and look:
[source,lua]
----
local channel = soundPlay(boom)
debugPrint("clip " .. boom .. " on channel " .. channel)
----
Tap the space bar quickly half a dozen times and watch the two numbers. The
clip number never changes. The channel number climbs -- 0, then 1, then 2 --
and once the earlier bangs have finished it drops back and starts reusing the
low numbers again.
This is the single thing about sound that catches everybody, so it is worth
saying slowly. *Two different kinds of number are in play.*
The *clip handle* from `soundLoad` is the recording. There is one of it. It
does not change, it is not playing or not playing, it is the sound sitting
in memory waiting to be used.
The *channel* from `soundPlay` is one performance of that recording. Singe has
sixteen of them, numbered `0` to `15`, and every sound that is currently
audible is using one. That is why the bangs overlap: the second press did not
interrupt the first, it started a second performance on the next free channel.
Everything that acts on a *sound you can hear* takes a channel: `soundStop`,
`soundIsPlaying`, `soundPause`, `soundResume`. Everything that acts on the
*recording* takes the handle: `soundPlay` and `soundUnload`. Hand one where
the other belongs and you will not always get an error, because `3` is a valid
channel number and also a perfectly good clip handle -- you will get silence,
or the wrong sound stopping, which is far harder to find.
Sixteen is a lot, but it is not endless. When every channel is busy, nothing
plays and `soundPlay` hands back `-1` instead of a channel number. A game that
fires a great many sounds at once should expect that, and you will write one
in a minute.
=== Volume
There are three volumes, and they multiply together.
The *master* effects volume covers everything. `soundGetVolume` reads it and
`soundSetVolume` sets it, on a scale of `0` to `63`.
[source,lua]
----
soundSetVolume(40)
----
Resist. That number is the player's, not yours: it starts at whatever they
asked for when they launched the game, and a game that overwrites it is a game
that is too loud in somebody's quiet room. Change the master volume only when
the player asks you to, from an options screen. `soundGetVolume` is there so
that an options screen can show what it currently is instead of guessing.
What you should reach for is the *channel* volume, which sits under the
master. Give `soundPlay` a third argument and this one performance is quieter:
[source,lua]
----
soundPlay(shootClip, 0, 35)
----
The `0` in the middle is how many times to repeat, which you have to give
because the volume comes after it. `0` means play it once. `-1` means loop
until something stops it, which is how you would do an engine drone or wind.
The manual's entry for `soundPlay` lists all three arguments.
The third volume is distance, for sounds placed in a 3D world. That is for
part four, when the game moves into three dimensions.
Music has a volume of its own, and it is on a different scale. More on that in
a moment.
=== Stopping, and Not Stopping
`soundStop` takes a channel and cuts it off where it is:
[source,lua]
----
soundStop(engineChannel)
engineChannel = -1
----
Note the second line. As soon as a channel stops, the engine is free to hand
that same number to the next `soundPlay`, so a channel number you are still
holding may now belong to a completely different sound. Throw it away the
moment you stop it. `-1` is the useful thing to put there, because it is the
same value `soundPlay` gives you when it could not play at all, so one test
covers both: `if channel >= 0 then`.
`soundStop` is for a sound that is wrong now -- the engine noise when the
engine dies, the alarm when the alarm is answered. It is the wrong tool for a
short effect. A half second explosion chopped off after a tenth of a second
sounds like a mistake, because it is one.
So most of the time, do nothing at all. Let it finish. A sound effect that you
start and never think about again ends by itself and frees its channel by
itself, which is exactly what you want.
This holds even at the end. `soundUnload` does not cut off a clip that is
still playing: the engine keeps the audio alive until the last channel using
it has stopped. You can unload in `onShutdown` without worrying that you have
silenced something.
`soundIsPlaying(channel)` answers whether a channel is making noise right now.
Its main use is not curiosity but restraint: an alarm that should sound once,
and not restart every time the game notices the danger is still there.
[source,lua]
----
function raiseAlarm()
if alarmChannel < 0 or not soundIsPlaying(alarmChannel) then
alarmChannel = soundPlay(alarmClip)
end
end
----
Read the condition aloud: play the alarm if there is no alarm channel, or if
the one you have has stopped. Otherwise leave it alone.
=== The Sound That Stutters
Here is the mistake. Everybody writes it, usually within a week of discovering
sound.
Suppose you wanted the ship to keep firing while the button is held down,
rather than once per press. You would do it the way you steer: a `firing`
variable set to `true` in `onInputPressed` and back to `false` in
`onInputReleased`, and the shot created in `onOverlayUpdate` while it is true.
That is a perfectly sensible thing to want, and the noise looks like it
belongs right there with the shot:
[source,lua]
----
function onOverlayUpdate()
if firing then
shots[#shots + 1] = { x = shipX, y = shipY }
soundPlay(shootClip)
end
...
----
What comes out is not a shot. It is a buzzing rattle, or a hard flat tone, or
a sound like a small machine breaking.
`onOverlayUpdate` runs about sixty times a second. Hold the button for one
second and you have asked for sixty performances of a clip that lasts about
a fifth of a second, each one starting on top of the last. Sixteen of them are
playing at once within a quarter of a second, the other forty-four come back
`-1` and are silently dropped, and what you hear is sixty copies of the same
attack piled on each other.
The fix is not a cleverer sound. It is playing the sound where the *event*
happens rather than where the *state* is true:
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_BUTTON1 then
soundPlay(shootClip, 0, 35)
end
end
----
`onInputPressed` happens once, when the button goes down. One press, one shot,
one noise.
If you really do want held fire, the answer is the same one in a different
shape: make the firing itself an event. Count frames, exactly as the walk
cycle counted them in lesson nine, and create a shot only every eighth frame.
The noise goes with the shot, so it plays seven or eight times a second
instead of sixty, and that is a machine gun rather than a rattle.
That is the rule, and it is worth carrying out of this lesson: *sounds belong
with events, not with conditions*. A press, a hit, a death, a pickup -- each
of those happens once and deserves one sound. "The button is down" and "the
ship is moving" are not events; they are true for hundreds of frames in a row,
and anything you play from them plays hundreds of times.
When a sound really does belong to a state that lasts -- an engine, wind, a
siren -- start it once with `-1` for the loop count, keep the channel, and
stop it when the state ends.
=== The Third Callback
Sometimes you do need to know when a sound has finished. Write this function
and the engine will call it:
[source,lua]
----
function onSoundCompleted(channel)
end
----
That is your third callback, after `onOverlayUpdate` in lesson one and the two
input callbacks in lesson three, and it works the same way as all of them: you
write it, you never call it, the engine calls you. The `channel` it hands you
is the channel that has just gone quiet -- the number `soundPlay` gave you
when you started it.
It fires when a clip runs out, when the last repeat of a loop finishes, *and*
when you stop a channel yourself with `soundStop`. That last one surprises
people who use the callback to chain one sound into the next: stopping a
channel on purpose still reports it as completed, and if you are not careful
the next sound in the chain starts anyway.
The game is about to use it for something worth doing. When the last life
goes, the ship's explosion plays, and the game holds still -- no GAME OVER,
no music fading -- until that explosion has actually finished. Then everything
stops at once. It takes a handful of lines and it is the difference between an
ending and a cut.
=== Music Is Not a Sound Effect
You could load a three minute tune with `soundLoad` and play it with
`soundPlay`. Do not. Music gets its own family of calls, for reasons that
matter:
* Music has its own volume, separate from the effects, so a player can turn
the music down and still hear the shots.
* There is no sixteen channel limit and no channel number. One handle is one
piece of music, and you stop it, pause it, and resume it by that handle.
* `musicStop` can fade a piece out over a second or two instead of cutting it.
The calls mirror the ones you already know:
[source,lua]
----
local theme = musicLoad("Singe/menuIntro.flac")
musicSetVolume(50)
musicPlay(theme, -1)
----
`musicLoad` gives a handle. `musicPlay` starts it, with the same loop count as
`soundPlay`: `-1` for forever. `musicStop(theme)` ends it, and
`musicStop(theme, 1500)` takes a second and a half to fade it away.
`musicUnload` gives it back.
Now the trap. *Music volume runs from 0 to 128, not 0 to 63.* Sound effects
use one scale and music uses another, twice as long, for historical reasons
that are no comfort at all when your music comes out at half the volume you
meant. When you are working with music, double the number you had in mind for
an effect.
The piece being loaded there is the engine's own menu music, out of the
`Singe` folder the engine unpacked into your work folder on your first run.
It is ten seconds long and it was written to fade out at the end, so looping
it sounds exactly like a ten second loop that keeps fading out. That is fine
for learning the calls and no good at all for a finished
game, which ships a longer piece of its own. Any of the usual formats will do:
the manual's Music section lists them, and Ogg Vorbis or MP3 are the sensible
choices, because a few minutes of music as a WAV is an enormous file.
=== The Game Gets a Soundtrack
Open the game from lesson nine. Everything here is an addition to it; nothing
comes out.
Four things to load, at the top, with the sprites:
[source,lua]
----
local shootClip = soundLoad(DIR .. "art/shoot.wav")
local boomClip = soundLoad(DIR .. "art/boom.wav")
local clickClip = soundLoad("Singe/click.wav")
local theme = musicLoad("Singe/menuIntro.flac")
----
The first two have `DIR` on them and the last two do not, and that is on
purpose. `DIR` means *your* game's folder. A name that begins with `Singe/`
means the engine's own folder instead, wherever that is -- which is why
`dofile("Singe/Framework.singe")` has worked since lesson seven without a
`DIR` either. Your own files need `DIR`; the engine's never do.
Two new variables, beside the others:
[source,lua]
----
local dying = false
local deathChannel = -1
----
`dying` is the moment between the last life going and the game being over:
the explosion is still sounding, and nothing on screen is moving.
The shot gets its noise in `onInputPressed`, right where the shot itself is
created, and the restart gets a click so that pressing `1` feels like it did
something:
[source,lua]
----
function onInputPressed(what)
if over then
if what == SWITCH_START1 then
soundPlay(clickClip)
startGame()
end
return
end
if what == SWITCH_LEFT then
goLeft = true
elseif what == SWITCH_RIGHT then
goRight = true
elseif what == SWITCH_BUTTON1 then
shots[#shots + 1] = { x = shipX + shipWidth / 2 - shotWidth / 2, y = shipY }
soundPlay(shootClip, 0, 35)
end
end
----
A rock that is shot bangs, and so does a rock that hits you. In `updateShots`,
beside the score:
[source,lua]
----
newRock(rock)
score = score + HIT_SCORE
gone = true
soundPlay(boomClip, 0, 45)
----
And in `updateRocks`, where the life is lost, the interesting part:
[source,lua]
----
if overlapping(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then
local channel = soundPlay(boomClip)
newRock(rock)
lives = lives - 1
if lives <= 0 then
dying = true
deathChannel = channel
if channel < 0 then
endGame()
end
end
end
----
When the last life goes, the explosion plays and its channel is remembered.
The game is now `dying`, which stops everything moving but does not yet say
GAME OVER.
The `if channel < 0` is the sixteen channel limit, handled. If every channel
happened to be busy at that exact moment, there is no explosion and no channel
to wait for, and without those two lines the game would sit in `dying`
forever, waiting for a sound that never played. Waiting for something that
cannot arrive is one of the easiest ways to hang a program, and the guard is
always cheaper than the bug.
Now the new callback, and the little function it shares with that guard:
[source,lua]
----
function endGame()
dying = false
deathChannel = -1
over = true
musicStop(theme, 1500)
end
function onSoundCompleted(channel)
if channel == deathChannel then
endGame()
end
end
----
The engine calls `onSoundCompleted` for *every* channel that stops, so the
first thing it does is check whether this one is the channel it cares about.
Everything else -- every shot, every rock -- passes through and is ignored.
`onOverlayUpdate` needs to hold still while the explosion runs, which is one
word:
[source,lua]
----
if not over and not dying then
updateShip()
updateShots()
updateRocks()
end
----
`startGame` clears the two new variables and starts the music over:
[source,lua]
----
dying = false
deathChannel = -1
musicSetVolume(50)
musicPlay(theme, -1)
----
And `onShutdown` gives back what was loaded:
[source,lua]
----
function onShutdown()
spriteUnload(shipSprite)
spriteUnload(rockSprite)
spriteUnload(shotSprite)
spriteUnload(starSprite)
soundUnload(shootClip)
soundUnload(boomClip)
soundUnload(clickClip)
musicUnload(theme)
end
----
Run it. Fire at the rocks, let one hit you three times, and listen to the end:
the last bang plays out in full, and only when it is gone does the music start
to fade and the words come up.
The finished script is in the `learn` folder as `10-sound.singe` if you want
to compare.
=== Try It
. *Make the shot quieter still.* Change the `35` in the shot's `soundPlay` to
`10`, then to `63`. Find the number where it sits under the explosions
instead of on top of them.
. *Turn the music down without touching the effects.* Change
`musicSetVolume(50)` to `musicSetVolume(15)`. Confirm that the bangs are
exactly as loud as they were, which is the whole reason music is its own
system.
. *Cut the ending off.* In `endGame`, change `musicStop(theme, 1500)` to
`musicStop(theme)`. Listen to both endings twice. The difference is a number
in one argument and it is not a small difference.
. *Give the ship an engine.* Start `soundPlay(clickClip, -1, 12)` in
`startGame`, keep the channel it gives you in a variable of its own, and
stop it in `endGame`. The click lasts a fiftieth of a second, so looping it
is a buzz, which is as close to an engine as this kit gets. Then take the
`soundStop` out again, play three games in a row, and listen to what you
have built.
. *Count the channels.* Print the channel number every time a rock explodes.
Then raise `ROCK_COUNT` until several explode at once, and keep raising it
until you see a `-1` go by.
=== Break It on Purpose
You have two volume scales in your head now, and sooner or later the wrong one
comes out of your fingers. Add this line on its own, just below the four
`soundLoad` and `musicLoad` lines:
[source,lua]
----
soundSetVolume(100)
----
The game refuses to start:
----
20:soundSetVolume: Invalid sound volume value: 100
----
The line number you get is the line you actually typed it on, so yours may not
be 20. Then the function that objected, and the value it objected to. The
message does not tell you what the range is -- that is what the manual's entry
for `soundSetVolume` is for -- but it does tell you the number it would not
take, and once you know that effects stop at 63 the fix is one digit.
`100` is a suspicious number to find in a volume, and it is worth knowing why
you typed it. Almost certainly you thought of volume as a percentage, because
every volume slider you have ever used goes to 100. Singe's does not. Effects
are `0` to `63` and music is `0` to `128`, and neither is a percentage of
anything.
This is the general shape of an engine error, as opposed to the Lua errors in
lesson one: a line number, the name of the engine function you called, and a
complaint about the values you handed it. When you see one, the manual's entry
for that function is the next thing to read.
=== What You Learned
* `soundLoad` reads a clip and returns a handle. Load once, at the top of the
file.
* `soundPlay` starts a clip and returns a *channel*, which is a different kind
of number from the handle.
* Singe mixes sixteen channels. When they are all busy, `soundPlay` returns
`-1` and nothing plays.
* Channel numbers are reused. Forget one as soon as you have stopped it.
* Short effects should be left alone to finish; `soundStop` is for sounds that
have become wrong.
* `soundUnload` does not cut off a clip that is still playing.
* Play a sound where the event happens, not where a condition is true, or it
plays sixty times a second.
* `onSoundCompleted(channel)` is a callback the engine calls each time a
channel goes quiet, for any reason.
* Music is a separate system with its own handles, its own volume, and no
channel limit.
* Effects volume is `0` to `63`. Music volume is `0` to `128`. They are not
percentages.
=== Next Time
Your rocks are hit when their rectangles overlap, which is why a shot that
passes through a corner of the sky still counts. In lesson eleven you get the
engine's own collision tests, learn what a hitbox is, and find out why the
rectangle around a round rock is the wrong shape.