Skip to content

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.