Audio and effects
Lesson 14.6
Playing a sound is only the start. You can also change how every sound in the game sounds while it plays: muffle it behind a wall, add an echo in a cave, make a voice sound like a radio, or create sound from nothing. This lesson shows the tools. The full list is in the Audio and effects guide.
Everything here runs on the client, in client events like client.sound_play and tick.client_post.
Every sound is an object
client.sound_play happens before a sound starts. e.handle is the sound itself, and you can keep it:
cl.on("client.sound_play", function(e)
local s = e.handle
print(s:event(), s:category(), s:distance_range()) --> minecraft:entity.cow.ambient neutral 16
end)
A sound object has methods, like the players you met before:
| Method | What it does |
|---|---|
s:set_volume(v), s:volume() |
Its volume (0 to 1). |
s:set_pitch(p), s:pitch() |
Its pitch (0.5 to 2). |
s:set_position(pos), s:position() |
Moves it. |
s:attach(entity, offset) |
It follows the entity until it ends. |
s:set_range(blocks) |
How far away you can hear it. |
s:is_playing() |
Still going? |
cl.sound.playing() gives a list of every sound playing right now, and client.sound_stop tells you when one ends.
Filters
A filter changes the sound’s colour. A low-pass filter removes the high notes, like a wall between you and the sound. A high-pass filter removes the low notes, like a tiny radio speaker:
s:set_filter{ type = "lowpass", gain = 0.6, gain_hf = 0.15 } -- behind a wall
s:set_filter{ type = "highpass", gain = 1.0, gain_lf = 0.3 } -- tinny
s:set_filter(nil) -- normal again
gain is the overall volume, gain_hf the high notes and gain_lf the low notes, each from 0 to 1. You can change a filter every tick: the game smooths the change, so it never clicks.
Effect buses
An echo or a reverb is too expensive to make for each sound. Instead you make one bus and send many sounds into it:
local cave = cl.audio.bus_create("reverb")
cave:set{ decay_time = 3.0, gain = 0.4 } -- a long, quiet echo of the room
cl.on("client.sound_play", function(e)
e.handle:set_send(cave, 0.5) -- half of this sound also goes into the cave
end)
You can make buses when your mod loads, at the top of your script: the game creates them as soon as its sound starts. Doing this for each sound works, but there is a shortcut. A default route sends every sound of some categories to a bus, including the ones already playing:
cl.audio.route_default(cave, 0.5, { categories = { "blocks", "hostile", "neutral", "players", "ambient" } })
Other effects: "echo", "chorus", "flanger", "distortion", "ring_modulator", "pitch_shifter", "equalizer" and more. cl.audio.max_sends() says how many buses one sound can feed (Sound Physics in the Client Settings uses some of them).
Where is the player?
local l = cl.audio.listener()
if l.in_fluid == "water" then
cl.audio.set_default_filter{ type = "lowpass", gain = 0.8, gain_hf = 0.1 } -- every sound muffled
else
cl.audio.set_default_filter(nil)
end
l.pos is the camera position, l.in_fluid is "water", "lava" or nil.
What is in the way?
cl.audio.trace_paths(from, to) looks at the blocks on a straight line, very fast:
local t = cl.audio.trace_paths(cl.vec(0, 64, 0), cl.vec(10, 64, 0))
print(t.blocks, t.by_material.stone, t.hit) --> 3 2 true
t.gain and t.gain_hf are ready-made filter values for that wall, so muffling a sound behind it is one line: s:set_filter{ type = "lowpass", gain = t.gain, gain_hf = t.gain_hf }.
Voice chat
Players talking in voice chat are sounds too. The event voice.stream_open gives you their stream:
cl.on("voice.stream_open", function(e)
if e.kind == "group" then
e.handle:set_filter{ type = "bandpass", gain = 1, gain_lf = 0.2, gain_hf = 0.4 } -- walkie-talkie
end
end)
voice.talking tells you when someone starts or stops talking (e.speaker_uuid, e.talking).
Making your own sound
A stream plays numbers your mod computes. Each number is one sample, from -1 to 1, 48000 per second:
local st
local phase = 0
cl.on("tick.client_post", function()
-- a stream opens once the game's sound runs, so open it in a tick, not when the mod loads
st = st or cl.audio.stream_open{ rate = 48000, channels = 1, relative = true, category = "ambient" }
if st:queued() < 4800 then -- less than 0.1 s left: add more
local samples = {}
for i = 1, 2400 do
samples[i] = math.sin(phase) * 0.1 -- a quiet 440 Hz beep
phase = phase + 2 * math.pi * 440 / 48000
end
st:push(samples)
end
end)
st:close() stops it.
Try it
Make every hostile mob sound echo: create an "echo" bus with a delay of 0.2 seconds, and send hostile sounds to it with a default route.
Show a solution
local echo = cl.audio.bus_create("echo")
echo:set{ delay = 0.2, feedback = 0.3 }
cl.audio.route_default(echo, 0.6, { categories = { "hostile" } })
A default route also catches the zombies that were already groaning when your mod started.
Recap: e.handle in client.sound_play is the sound; set_filter colours it, buses (cl.audio.bus_create) add echo and reverb, route_default sends whole categories, trace_paths sees the walls, voice chat streams are sounds, and cl.audio.stream_open plays sound your mod makes.