Skip to content

Audio and effects

Lesson 9.5

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 CL_EV_CLIENT_SOUND_PLAY and CL_EV_TICK_CLIENT_POST.

Every sound has a handle

CL_EV_CLIENT_SOUND_PLAY happens before a sound starts. handle in its payload is the sound itself:

#include <clight_easy.h>

static void on_sound(void* payload, void* user) {
    cl_ev_sound_play* e = CL_EVENT(cl_ev_sound_play, payload);
    (void)user;
    if (e->category == CL_SOUND_NEUTRAL) cl_sound_set_volume(e->handle, 0.3f);   /* quieter animals */
}

static cl_result mod_main(void) { return CL_ON(CL_EV_CLIENT_SOUND_PLAY, on_sound); }

CLIGHT_MOD_ENTRY(mod_main)

The cl_sound handle works as long as the sound plays (then calls return CL_E_STALE_HANDLE):

Function What it does
cl_sound_set_volume(s, v), cl_sound_volume(s, &v) Its volume (0 to 1).
cl_sound_set_pitch(s, p) Its pitch (0.5 to 2).
cl_sound_set_position(s, pos), cl_sound_position(s, &pos) Moves it.
cl_sound_attach(s, entity, offset) It follows the entity.
cl_sound_set_range(s, blocks) How far away it can be heard.
cl_sound_playing(out, cap, &n) Every sound playing right now.

CL_EV_CLIENT_SOUND_STOP tells you when a sound ends.

Filters

A low-pass filter removes the high notes (a wall between you and the sound), a high-pass filter the low notes (a tiny radio speaker). Start from CL_AUDIO_FILTER_INIT and change what you need:

cl_audio_filter wall = CL_AUDIO_FILTER_INIT;     /* a low-pass with every gain at 1 */
wall.gain = 0.6f;
wall.gain_hf = 0.15f;
cl_sound_set_filter(e->handle, &wall);

cl_audio_filter radio = CL_AUDIO_FILTER_INIT;
radio.type = CL_FILTER_HIGHPASS;
radio.gain_lf = 0.3f;
cl_sound_set_filter(e->handle, &radio);

cl_sound_set_filter(e->handle, NULL);           /* normal again */

Changes are smoothed by the engine, so you can update a filter every tick without clicks.

Effect buses

An echo or reverb is one shared bus that many sounds feed:

static cl_audio_bus g_cave;

/* buses and routes can be made as soon as the mod loads: the game creates them when its sound starts */
static cl_result mod_main(void) {
    cl_result r = cl_audio_bus_create(CL_STR("reverb"), &g_cave);
    if (r != CL_OK) return r;
    cl_audio_bus_set(g_cave, CL_STR("decay_time"), 3.0f);
    cl_audio_bus_set(g_cave, CL_STR("gain"), 0.4f);
    /* every world sound goes into the cave: blocks, mobs, players, ambience, weather, voice */
    return cl_audio_route_default(g_cave, 0.5f, 0, true, NULL);
}

cl_audio_route_default sends every sound of the categories to the bus, including sounds already playing. 0 means every world category; pick some with CL_AUDIO_BLOCKS | CL_AUDIO_HOSTILE | .... One sound can also feed a bus by itself with cl_sound_set_send(s, bus, gain, NULL). cl_audio_max_sends() says how many buses one sound can feed. Other effects: "echo", "chorus", "flanger", "distortion", "ring_modulator", "pitch_shifter", "equalizer", and more (the reference lists their parameters).

The listener and the tracer

cl_audio_listener l;
if (cl_audio_get_listener(&l) == CL_OK && l.in_fluid == CL_FLUID_WATER) {
    cl_audio_filter under = CL_AUDIO_FILTER_INIT;
    under.gain = 0.8f;
    under.gain_hf = 0.1f;
    cl_audio_set_default_filter(&under);   /* every positional sound */
}

cl_audio_trace_paths looks at the blocks on a straight line, natively and fast:

cl_audio_trace_opts o = CL_AUDIO_TRACE_OPTS_INIT;   /* max_blocks = 48 */
cl_audio_trace t;
if (cl_audio_trace_paths(sound_pos, l.pos, &o, &t) == CL_OK && t.hit) {
    cl_audio_filter f = CL_AUDIO_FILTER_INIT;
    f.gain = t.gain;
    f.gain_hf = t.gain_hf;          /* ready-made values for that wall */
    cl_sound_set_filter(s, &f);
}

t.stone, t.wood, … count the blocks of each material. cl_audio_trace_acoustics gives the whole room (reverb time, how closed it is) from a worker thread: call it every tick until ready is true.

Voice chat

Players talking in voice chat are sounds too:

static void on_voice(void* payload, void* user) {
    cl_ev_voice_stream* e = CL_EVENT(cl_ev_voice_stream, payload);
    (void)user;
    if (e->kind == CL_VOICE_GROUP) {
        cl_audio_filter radio = CL_AUDIO_FILTER_INIT;
        radio.type = CL_FILTER_BANDPASS;
        radio.gain_lf = 0.2f;
        radio.gain_hf = 0.4f;
        cl_sound_set_filter(e->handle, &radio);   /* walkie-talkie */
    }
}
/* in mod_main: CL_ON(CL_EV_VOICE_STREAM_OPEN, on_voice); */

Making your own sound

A stream plays 16-bit samples your mod computes:

static cl_audio_stream g_tone;
static double g_phase;

static void on_tick(void* payload, void* user) {
    (void)payload;
    (void)user;
    if (!g_tone.h) {  /* streams open once the game's sound runs (in a tick or an event) */
        cl_audio_stream_opts o = CL_AUDIO_STREAM_OPTS_INIT;   /* 48000 Hz, mono */
        o.relative = true;
        o.category = CL_SOUND_AMBIENT;
        if (cl_audio_stream_open(&o, &g_tone) != CL_OK) return;
    }
    int32_t queued = 0;
    cl_audio_stream_queued(g_tone, &queued);
    if (queued >= 4800) return;                 /* 0.1 s ahead is enough */
    int16_t buf[2400];
    for (int i = 0; i < 2400; ++i) {
        buf[i] = (int16_t)(sin(g_phase) * 3000);
        g_phase += 2 * 3.14159265 * 440 / 48000;
    }
    cl_audio_stream_push(g_tone, (cl_str){(const char*)buf, sizeof buf});
}

static cl_result mod_main(void) { return CL_ON(CL_EV_TICK_CLIENT_POST, on_tick); }

(Include <math.h> for sin.) The samples are little-endian, which is what WebAssembly uses, so a plain int16_t array works.

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
#include <clight_easy.h>

static cl_result mod_main(void) {
    cl_audio_bus echo;
    cl_result r = cl_audio_bus_create(CL_STR("echo"), &echo);
    if (r != CL_OK) return r;
    cl_audio_bus_set(echo, CL_STR("delay"), 0.2f);
    cl_audio_bus_set(echo, CL_STR("feedback"), 0.3f);
    return cl_audio_route_default(echo, 0.6f, CL_AUDIO_HOSTILE, true, NULL);
}

CLIGHT_MOD_ENTRY(mod_main)

A default route also catches the zombies that were already groaning when your mod started.

Recap: handle in CL_EV_CLIENT_SOUND_PLAY is the sound; cl_sound_set_filter colours it, buses (cl_audio_bus_create) add echo and reverb, cl_audio_route_default sends whole categories, cl_audio_trace_paths sees the walls, voice chat streams are sounds, and cl_audio_stream_open plays sound your mod makes.