Sound
A sound lasts longer than a step. A jump sound plays for half a second,
which is 60 fixed steps, and music plays for minutes. But your game is pure
functions: step and view return values and keep nothing between calls.
So who remembers that a sound is still playing, and who stops it?
rocco splits sounds into two kinds, and the host keeps both playing between steps:
- A sound that your game fires and forgets, such as a jump or a pickup, is
a Play. Your game returns it as a Command, like
Load. The host plays it to its end, and your game forgets it. - A sound that your game controls, such as music, the hum of a machine or
the creak of a door, is a Speaker. It is a Node in the
nodesof the Scene, like a Draw. It plays for as long as it stays in the Scene, and it repeats or plays once.
Tutorial chapter 8 uses both: a blip when Pip takes a pickup, music, a hum at the door, and a creak while the door rises.
Sounds are assets
A Sound is an asset in a Pack, like a mesh. Put .wav and .ogg files in
assets/sound/, next to your main.roc. A Sound takes the name sound/
and its file stem, so assets/sound/blip.wav is sound/blip.
Load Sounds with the Load Command, and keep their ids from the Manifest
in loaded. Sound.resolve finds a Sound id in the Manifest, as
Mesh.resolve finds a mesh id. Tutorial chapter 8 loads four Sounds in
the same Pack as its mesh:
init : Vocabulary.Config -> { model : Model, commands : List(Command) }
init = |_config| {
model = { cube: Mesh.primitive(Cube), sphere: Mesh.primitive(Sphere), pip: 0, sounds: { blip: 0, creak: 0, hum: 0, music: 0 }, pos: V.zero, yaw: 0.0, pickups: start_pickups, door_lift: 0.0, phase: Playing }
{ model, commands: [Load({ pack: "pip", assets: ["pip", "sound/blip", "sound/creak", "sound/hum", "sound/music"] })] }
}
loaded : Model, Str, Manifest -> Model
loaded = |model, _pack, manifest| {
sounds = { blip: Sound.resolve(manifest, "sound/blip"), creak: Sound.resolve(manifest, "sound/creak"), hum: Sound.resolve(manifest, "sound/hum"), music: Sound.resolve(manifest, "sound/music") }
{ ..model, pip: Mesh.resolve(manifest, "pip"), sounds }
}A Sound id goes in a Play or a Speaker. Assets and Packs
explains Packs, the Manifest and Config.assets, which lists the Sound names
after the meshes and the skies.
The Manifest also gives each Sound’s length. Sound.duration(manifest, name) gives it in seconds, at pitch 1. See Loop and Once.
The host decodes each Sound in full when it loads, and keeps the samples in memory until the Pack unloads. It does not stream from disk. A minute of stereo music takes about 23 MB, so load long music only while you need it.
When a Sound unloads with its Pack, the host first stops each Play and
Speaker of it. If two files share a stem, such as jump.ogg and jump.wav,
the host loads the first one in sorted order, here jump.ogg, and logs once.
Play a sound once
Sound.play(id) gives a Play of the Sound at its recorded gain and pitch,
Here at the Listener. Return it from init or step, and the host starts
the sound in the same fixed step, right after view moves the Listener.
Tutorial chapter 8 plays a blip when Pip takes a pickup:
step : Model, Vocabulary.Input, Vocabulary.QueryResults, F32 -> { model : Model, commands : List(Command) }
step = |model, input, results, dt| {
contacts = results.contacts
next = match model.phase {
Playing => collect(model, contacts) |> push_out(contacts) |> walk(input, dt) |> raise_door(dt) |> win(contacts)
Won => spin(model, dt)
}
{ model: next, commands: quit_on_escape(input).concat(blip(model, next)) }
}
# A blip plays once when Pip collects a pickup. Two pickups in one step make one blip.
blip : Model, Model -> List(Command)
blip = |before, after|
if List.len(after.pickups) < List.len(before.pickups) {
[Sound.play(after.sounds.blip)]
} else {
[]
}For another gain, pitch or place, write the Play yourself:
Play({ sound: model.sounds.blip, gain: 0.5, pitch: 1.5, at: At({ pos: model.pos, range: 8.0 }) })A Play plays to its end, or until its Pack unloads. Your game can’t stop it, and it stays at the place it started at. If a sound must stop early or follow a moving object, make it a Speaker.
Speakers
A Speaker is a Description, like a Draw. Your game says which sounds play
now, and the host works out what to start and stop. Start each Speaker from
Speaker.default, which repeats its Sound Here at gain
1 and pitch 1, and set its id and sound. Tutorial chapter 8 plays its
music this way:
music_speaker_id : U64
music_speaker_id = 1
music : Model -> Vocabulary.Speaker
music = |model| { ..Speaker.default, id: music_speaker_id, sound: model.sounds.music, gain: 0.5 }A Speaker goes in nodes, at the root or inside a Group. After each
view, the host pairs each Speaker with the Speaker that had the same id
anywhere in the previous Scene:
- A new id starts the Sound from its start. The Speakers of the first
viewstart before the firststep. - An id that stays keeps playing, even if the Speaker moved to another
Group. A changed
gain,pitchoratapplies on the next fixed step, and the Sound keeps its place. - A changed Sound id stops the old Sound at once and starts the new one from its start.
- An id that leaves the Scene stops the Sound with a 5 millisecond fade.
Speaker ids are any U64, and they are separate from Draw ids and Group
ids. Keep a Speaker’s id the same for as long as it plays. If two Speakers
in one Scene share an id, the first one in the tree plays, and the host logs
once. The host walks the tree depth first: a Group’s children come before
the Node after the Group.
Loop and Once
A Speaker’s repeat says what happens at the end of its Sound:
Loop, the default, starts the Sound again from its start. Use it for music and for the hum of a machine.Onceplays the Sound one time. Then the Speaker stays paired and silent, and it gives its voice back. It does not play again while it stays in the Scene.
Tutorial chapter 8 has one of each at its door. The hum is a Loop, and
the creak is a Once:
hum_speaker_id : U64
hum_speaker_id = 2
creak_speaker_id : U64
creak_speaker_id = 3
# Past this distance from the camera, the door's Speakers are silent.
door_range : F32
door_range = 10.0
# The door's Speakers sit at the centre of its Group, so they rise with it.
door_speakers : Model -> List(Vocabulary.Node)
door_speakers = |model| {
at = At({ pos: V.zero, range: door_range })
hum = { ..Speaker.default, id: hum_speaker_id, sound: model.sounds.hum, gain: 1.0 - model.door_lift / door_height, at }
creak = { ..Speaker.default, id: creak_speaker_id, sound: model.sounds.creak, at, repeat: Once }
match model.phase {
Playing if is_open(model) => [Speaker(creak)]
Playing if model.door_lift > 0.0 => [Speaker(hum), Speaker(creak)]
Playing => [Speaker(hum)]
Won => []
}
}The creak joins the Scene when the door starts to rise, so it starts then. When Pip walks through the door, the creak leaves the Scene, so it stops, even if its Sound has not ended. If Pip waits, the creak ends by itself, and the silent Speaker stays until Pip walks through.
To play a Once Speaker again, give it a new id, or leave it out of the
Scene for one step.
A changed repeat applies to the Sound that plays. A Loop that changes to
Once stops at the end of the current pass. A Once that changes to Loop
keeps repeating. A Once that has ended does not start again.
The host does not tell your game when a Once ends. If your game needs to
know, read the Sound’s length with Sound.duration in loaded, keep it in
your Model, and count the time in step. The length is at pitch 1, so
divide it by the Speaker’s pitch. At pitch 2, a Sound of 4 seconds ends
after 2 seconds:
creak_seconds = Sound.duration(manifest, "sound/creak") / creak_pitchStopping and fading
The 5 millisecond fade keeps the speaker from clicking, but you hear it as
a cut, not a fade-out. To fade a Speaker out, lower its gain over many
steps, then remove it. Tutorial chapter 8 fades its hum this way while the
door rises.
A Speaker that leaves the Scene and comes back starts again from the start
of its Sound. There is no pause. To silence a Loop Speaker and keep its
place, set its gain to 0 and keep it in the Scene.
To switch from one piece of music to another smoothly, give each piece its own Speaker id. Lower the gain of the old Speaker while you raise the gain of the new one, then remove the old Speaker. A changed Sound id on one Speaker cuts with no fade, and the jump in level can click.
A sound that outlives its object
A Speaker in a Group leaves the Scene when its Group leaves, and its Sound
stops. For the creak of a door that the player walks through, that is what
you want. But a rocket that your game removes when it hits a wall still
needs its explosion to play to the end. Make that sound a Play at the
rocket’s last pos, not a Speaker in the rocket’s Group. The Play does not
need the rocket to stay in the Scene.
Make a Sound that loops cleanly
The host repeats a Loop Speaker’s samples from the last one straight back
to the first, with no crossfade. If the last sample does not lead into the
first, you hear a click each time the Sound repeats. Cut a loop at a point
where the wave crosses zero, or make each tone a whole number of periods
long.
make_sounds.py builds the
tutorial’s Sounds this way. The hum is one second of tones at whole
frequencies, such as 100 Hz and 150 Hz. Each note of the music fades to
zero before the next one starts. The creak plays once, so it only fades to
zero at its end.
Gain and pitch
gain is linear. 1 plays the Sound as recorded, 0.5 plays it at half the
amplitude, and 0 is silent. A gain above 1 is louder than the recording and
can clip. A negative gain plays as 0.
pitch is the playback rate. 1 plays the Sound as recorded. 2 plays it an
octave higher and twice as fast, and 0.5 an octave lower and twice as
long. A pitch of 0 or less plays at 1, and the host logs once.
A Speaker’s gain or pitch changes in one step. A small change each step
sounds smooth, but a big jump in the gain of a Here Speaker can click.
Change it over a few steps.
The Listener and the place
The Listener is the ear that hears a placed sound. In rocco, the Listener is
the camera of the current Scene. The host moves it once per fixed step, at
the camera’s eye, facing its target, with its up. It does not move
between steps, and the debug camera does not move it.
The at field of a Play or a Speaker says where it sounds:
Hereplays at the Listener, with no direction. The Sound is as loud in each ear as its gain says, wherever the camera is. Use it for music, the HUD and the player’s own sounds. AHereSpeaker ignores its Group.At({ pos, range })plays atpos. Its gain falls off linearly with the distance from the Listener, from full at the Listener to zero atrange, like a Point Light. Pastrangeit is silent. Arangeof 0 or less is silent.
A placed sound also pans to the side it is on. To the right of the
Listener, the right ear gets the full gain and the left ear gets a fifth of
it. Straight ahead, each ear gets half. So a placed sound right
in front of the camera is quieter than the same sound Here. Raise the
gain of placed sounds if they sound too soft next to the others.
A Play’s pos is in the world, and the Play stays where it started. A
Speaker’s pos is in the space of its Group, as a Draw’s pos is. A root
Speaker’s pos is in the world.
So to place a sound on an object that moves, put its Speaker in the
object’s Group. The Speaker moves with the Group, and your game gives it no
pos from the Model. Tutorial chapter 8 puts the hum and the creak at
V.zero in the door’s Group, the centre of the door, so they rise with the
door. The host takes a Speaker’s place from the current Scene once per fixed
step, as it moves the Listener. It does not interpolate it.
If your camera stands still, every placed sound keeps the same level and side. Tutorial chapter 8 makes its camera follow Pip, so the hum grows as Pip walks to the door.
Voices
Each Play and each Speaker that sounds takes a voice. capacity.voices in
the Launch sets how many voices there are, 64 by default. A
Play gives its voice back when it ends, and so does a Once Speaker. A
Loop Speaker holds its voice for as long as it is in the Scene, even at
gain 0 or out of range. A Speaker that leaves holds its voice until its fade
ends.
When every voice is in use:
- A new Play takes the voice of the oldest Play, which stops. If Speakers hold every voice, the Play plays nothing.
- A new Speaker waits silent. It starts when a voice frees, if it is still in the Scene. Waiting Speakers start in the order of the tree.
A Play never takes a Speaker’s voice, and a Speaker never takes a Play’s voice. Each of these cases logs once per run.
capacity.sounds sets how many Sounds can be loaded at once, 64 by
default. When the table is full, a Load gives the new name id 0, and the
host logs once.
Bad Sound ids
A Sound id of 0, an id from a Pack that has unloaded, or an id the host never gave plays nothing. The host logs each id once. There is no fallback sound, as there is a fallback mesh, so a misspelt name is silent. Read the log.
A Speaker with a bad id tries again each step, so it starts as soon as you
give it a good id. If you unload the Pack of a Speaker’s Sound and load it
again, the Sound gets a new id. Put the new id in the Speaker to start it
again. The new id is a changed Sound id, so a Once that has ended plays
again.
With no audio device
At startup, the host opens the default audio device. If it can’t, it logs once and runs the game with no sound:
the audio device did not open: <the reason>. The run has no sound.A Headless run never opens a device. Everything else works the same with
no device. Your game gets the same calls, the host still runs each Play and
Speaker, and a Play or a Once Speaker still ends after its length. So
tests and CI run on a machine with no sound card.
Sound and the Bridge
The Bridge shows each Play in the commands of a step
reply, and each Speaker in the scene reply, in its place in the tree. So
a check can find the sound that a step played without a speaker.
scripts/bridge_check.py walks Pip into a pickup in chapter 8, and checks
that the step reply holds the Play of the blip:
{"op":"step","n":30,"held":["W"]}
{"ok":true,"steps":30,"commands":[{"Play":{"sound":1,"gain":1,"pitch":1,"at":"Here"}}],"alloc":{...}}Then it takes the other pickups, and checks that the creak is in the children of the door’s Group:
{"Speaker":{"id":3,"sound":2,"gain":1,"pitch":1,"at":{"At":{"pos":{"x":0,"y":0,"z":0},"range":10}},"repeat":"Once"}}While the Bridge pauses the run, the sounds play on. A Play or a Once
Speaker plays to its end, and the Loop Speakers keep repeating. Nothing
changes until the next step.
Limits
- No master volume, mute or pause. Scale the gains that your game returns.
- A Play can’t stop. Use a Speaker for a sound that must stop.
- The host does not tell your game when a
OnceSpeaker ends. UseSound.duration. - No streaming. Each Sound is decoded in full when it loads.
.wavand.oggonly. No.flacor.mp3.- One Listener, the camera.