Sound and music
Status: Implemented (API 1.7). Everything on this page is in
client/clight/idl/sound.idlandclight.idl(declared inclight.h). Planned additions are listed at the end.
Function tables: sound, audio, voice. Walkthrough: Guide: Audio and effects.
Celestial’s audio runs on OpenAL Soft (optional HRTF) on a dedicated mixing thread, with streamed Vorbis music. Sound events, sounds.json, categories, attenuation and subtitles all follow vanilla.
Your own sounds
Put them in your mod’s assets, in the vanilla format:
assets/<modid>/sounds.json { "ruby_chime": { "sounds": ["<modid>:ruby_chime"], "subtitle": "subtitles.<modid>.ruby_chime" } }
assets/<modid>/sounds/ruby_chime.ogg
sounds.json supports everything vanilla does (sounds, subtitle, replace, stream, weight, pitch, volume, attenuation_distance, preload), plus distance_model and rolloff (API 1.7, see below). The mod’s assets/ folder is mounted as the resource pack mod/<modid>, so the sound event <modid>:ruby_chime exists as soon as the mod loads. Use Ogg Vorbis, mono for positional sounds (stereo sounds don’t attenuate, as in vanilla).
Sound ids are namespace:path sound events; a missing namespace means minecraft.
Categories
typedef int32_t cl_sound_category; // = vanilla SoundSource, same order (and the volume sliders)
enum { CL_SOUND_MASTER = 0, CL_SOUND_MUSIC = 1, CL_SOUND_RECORDS = 2, CL_SOUND_WEATHER = 3, CL_SOUND_BLOCKS = 4,
CL_SOUND_HOSTILE = 5, CL_SOUND_NEUTRAL = 6, CL_SOUND_PLAYERS = 7, CL_SOUND_AMBIENT = 8, CL_SOUND_VOICE = 9, CL_SOUND_UI = 10 };
Server-side playback (TICK)
These go over the vanilla sound packets, so nothing is added to the protocol. Mod sound ids that aren’t in the vanilla registry are sent as direct sound events (the packet’s inline holder with the id), so a vanilla client with the mod’s resource pack plays them too.
// Level#playSound(null, ...): every player within the attenuation range hears it. On the client level it plays locally.
cl_result cl_sound_play(cl_level level, cl_vec3 pos, cl_str sound, cl_sound_category category, float volume, float pitch);
// Level#playSound(except, ...): not sent to `except` (the player who caused it predicts the sound itself).
cl_result cl_sound_play_except(cl_level level, cl_entity except, cl_vec3 pos, cl_str sound, cl_sound_category category, float volume, float pitch);
// Only this player hears it.
cl_result cl_sound_play_to_player(cl_entity player, cl_vec3 pos, cl_str sound, cl_sound_category category, float volume, float pitch);
// Entity#playSound: at the entity's position, in its level (works on client entities too, locally).
cl_result cl_sound_play_at_entity(cl_entity e, cl_str sound, cl_sound_category category, float volume, float pitch);
// The stop-sound packet: sound "" = all sounds; CL_SOUND_MASTER = every category.
cl_result cl_sound_stop_for_player(cl_entity player, cl_str sound, cl_sound_category category);
Client-side playback
Client functions run on the client thread (client tick or render callbacks); from another thread they do nothing.
cl_result cl_sound_play_local(cl_vec3 pos, cl_str sound, cl_sound_category category, float volume, float pitch, bool looping, cl_sound* out);
cl_result cl_sound_play_ui(cl_str sound, float volume, float pitch, cl_sound* out); // SimpleSoundInstance.forUI
cl_result cl_sound_stop(cl_sound s);
bool cl_sound_is_playing(cl_sound s);
cl_result cl_sound_stop_local(cl_str sound /*"" = all*/, cl_sound_category category /*MASTER = all*/);
bool cl_sound_exists(cl_str sound); // defined in any loaded sounds.json (vanilla, packs, mods)
cl_sound_play_local returns CL_E_NOT_FOUND for an unknown sound event. A cl_sound stays valid until the sound ends.
Music
cl_result cl_music_play(cl_str sound, bool replace_vanilla); // MusicManager#startPlaying for your track (streamed)
cl_result cl_music_stop(void);
bool cl_music_is_playing(void);
cl_music_play stops the current background music and plays your track in the MUSIC category. When it ends, vanilla’s situational music resumes after its usual delay, unless replace_vanilla is set: then vanilla music stays off until you call cl_music_stop.
Events
| Event | Context | Cancellable | Mutable | Payload |
|---|---|---|---|---|
client.sound_play |
client thread | yes | volume, pitch |
cl_ev_sound_play { sound, category, pos, volume, pitch, positional, handle } |
client.sound_stop |
client thread | no | cl_ev_sound_stop { handle, sound, category } |
|
voice.stream_open |
client thread | no | cl_ev_voice_stream { handle, speaker_uuid, speaker, kind } |
|
voice.stream_close |
client thread | no | cl_ev_voice_stream { handle, speaker_uuid, speaker, kind } |
|
voice.talking |
client thread | no | cl_ev_voice_talking { speaker_uuid, talking, whisper } |
client.sound_play fires before any sound starts on the client (vanilla, server-sent or modded). Cancel it to keep a sound silent. handle (API 1.7) is the sound itself: everything on this page that takes a cl_sound works on it, already inside the handler (a filter or send set there applies from the first sample). The hook into the sound engine is only installed when mods are loaded, and the events cost nothing while nobody subscribes. A handler that plays a sound itself doesn’t re-trigger the event.
client.sound_stop fires within a tick after a sound ended or was stopped. kind of a voice stream is CL_VOICE_PROXIMITY, WHISPER, GROUP (not positional), LOCATION or STATIC; speaker is the speaker’s client entity, or 0 when it isn’t loaded.
Every playing sound (API 1.7)
Every sound on the client has a cl_sound handle: vanilla and server sounds (from client.sound_play or cl_sound_playing), your own (cl_sound_play_local), Simple Voice Chat streams (voice.stream_open) and your PCM streams (cl_audio_stream_sound). A handle stays valid while the sound plays; afterwards calls return CL_E_STALE_HANDLE.
cl_result cl_sound_set_position(cl_sound s, cl_vec3 pos); // not for listener-relative sounds or voice chat
cl_result cl_sound_position(cl_sound s, cl_vec3* out);
cl_result cl_sound_set_volume(cl_sound s, float volume); // the sound's own volume; voice / PCM streams: a gain 0..16
cl_result cl_sound_volume(cl_sound s, float* out);
cl_result cl_sound_set_pitch(cl_sound s, float pitch); // 0.5..2 like vanilla
cl_result cl_sound_pitch(cl_sound s, float* out);
cl_result cl_sound_attach(cl_sound s, cl_entity e, cl_vec3 offset); // follows the entity every tick
cl_result cl_sound_detach(cl_sound s);
cl_result cl_sound_get_category(cl_sound s, cl_sound_category* out);
cl_result cl_sound_event(cl_sound s, char* buf, size_t cap, size_t* out_len); // "voice:<uuid>", "stream:<modid>" for streams
bool cl_sound_is_relative(cl_sound s);
bool cl_sound_is_voice(cl_sound s);
cl_result cl_sound_range(cl_sound s, float* out); // blocks: Sound#getAttenuationDistance x max(volume, 1)
cl_result cl_sound_set_range(cl_sound s, float range);
cl_result cl_sound_set_distance_model(cl_sound s, cl_distance_model model, float rolloff);
cl_result cl_sound_playing(cl_sound* out, size_t cap, size_t* out_count);
Distance models. Vanilla fades linearly to silence at the range (CL_DISTANCE_LINEAR). CL_DISTANCE_INVERSE and CL_DISTANCE_EXPONENTIAL fall off like real sound (reference distance 1 block; the level stops falling at the range). In sounds.json a sound entry may also say "distance_model": "inverse" (or "exponential") and "rolloff": 1.5; vanilla ignores both fields, so vanilla packs keep the linear model.
Filters (API 1.7)
typedef struct cl_audio_filter { uint32_t struct_size; cl_audio_filter_type type; float gain, gain_lf, gain_hf; } cl_audio_filter;
// CL_AUDIO_FILTER_INIT = a low-pass that does nothing (gains 1)
cl_result cl_sound_set_filter(cl_sound s, const cl_audio_filter* filter); // NULL: none
CL_FILTER_LOWPASS uses gain and gain_hf (muffled through a wall), CL_FILTER_HIGHPASS gain and gain_lf (a tinny radio), CL_FILTER_BANDPASS all three. Gains are 0…1. The filter is combined with the default filter, the underwater muffling and Sound Physics (the gains multiply).
Effect buses (API 1.7)
A bus is an OpenAL EFX auxiliary effect slot. Several mods may own buses (at most 64); sounds send to them like on a mixing desk.
cl_result cl_audio_bus_create(cl_str effect, cl_audio_bus* out);
cl_result cl_audio_bus_set(cl_audio_bus bus, cl_str param, float value);
cl_result cl_audio_bus_set_vec3(cl_audio_bus bus, cl_str param, cl_vec3 value); // reverb "reflections_pan", "late_reverb_pan"
cl_result cl_audio_bus_set_gain(cl_audio_bus bus, float gain);
cl_result cl_audio_bus_set_output(cl_audio_bus bus, cl_audio_bus target); // chaining (echo -> reverb); 0 = the main mix
cl_result cl_audio_bus_remove(cl_audio_bus bus);
cl_result cl_audio_bus_effect(cl_audio_bus bus, char* buf, size_t cap, size_t* out_len);
cl_result cl_sound_set_send(cl_sound s, cl_audio_bus bus, float gain, const cl_audio_filter* filter);
int32_t cl_audio_max_sends(void);
bool cl_audio_efx_available(void);
| Effect | Parameters (EFX names in snake case) |
|---|---|
reverb (EAX reverb) |
density, diffusion, gain, gain_hf, gain_lf, decay_time, decay_hf_ratio, decay_lf_ratio, reflections_gain, reflections_delay, late_reverb_gain, late_reverb_delay, echo_time, echo_depth, modulation_time, modulation_depth, air_absorption_gain_hf, hf_reference, lf_reference, room_rolloff, decay_hf_limit; vectors reflections_pan, late_reverb_pan |
echo |
delay, lr_delay, damping, feedback, spread |
chorus, flanger |
waveform, phase, rate, depth, feedback, delay |
distortion |
edge, gain, lowpass_cutoff, eq_center, eq_bandwidth |
ring_modulator |
frequency, highpass_cutoff, waveform |
pitch_shifter |
coarse_tune, fine_tune |
equalizer |
low_gain, low_cutoff, mid1_gain, mid1_center, mid1_width, mid2_gain, mid2_center, mid2_width, high_gain, high_cutoff |
compressor |
on |
autowah |
attack_time, release_time, resonance, peak_gain |
frequency_shifter |
frequency, left_direction, right_direction |
vocal_morpher |
phoneme_a, phoneme_a_coarse_tuning, phoneme_b, phoneme_b_coarse_tuning, waveform, rate |
Buses, chains, routes and the default filter can be set up as soon as a mod loads: the core keeps them and creates them in the sound engine when it starts (an unknown parameter name is then reported in the log). The calls that need the running engine (sound handles, streams, the listener, tracing, the monitor) return CL_E_NOT_LOADED before it started, and CL_E_WRONG_SIDE on a dedicated server. Values are clamped to the EFX ranges. Every change (bus parameters, gains, filters, sends) is smoothed by the engine over about 20 ms, so updating them every tick never clicks; whole-number parameters (waveform, phase, tunings, on) apply at once. An unknown effect or parameter is CL_E_NOT_FOUND.
Sends. A source has a fixed number of auxiliary sends. Celestial asks OpenAL Soft for 6; the built-in Sound Physics (Client Settings) keeps 4 of them while it is on, and cl_audio_max_sends() tells you how many are left for mod buses (6, or 2 with Sound Physics on). Default routes take the first sends, then the sound’s own cl_sound_set_send calls; sends past the limit are dropped (the later ones). gain 0 on cl_sound_set_send takes the sound out of that bus, also out of a default route to it. Without an EFX device (cl_audio_efx_available() false) everything still works and nothing is heard.
Default routing (API 1.7)
cl_result cl_audio_route_default(cl_audio_bus bus, float gain, cl_audio_categories categories, bool positional_only,
const cl_audio_filter* filter); // one route per bus; again = replace
cl_result cl_audio_route_remove(cl_audio_bus bus);
cl_result cl_audio_set_default_filter(const cl_audio_filter* filter); // every positional sound; NULL clears
Every new and playing sound of the categories (CL_AUDIO_BLOCKS | CL_AUDIO_HOSTILE | ...; 0 = every world category: weather, blocks, hostile, neutral, players, ambient, voice) gets a send to the bus. positional_only leaves out music, UI and listener-relative sounds. filter shapes only that send. One mod can then give the whole game a cave reverb, or muffle everything underwater, without touching sounds one by one.
Listener (API 1.7)
typedef struct cl_audio_listener { cl_vec3 pos, forward, up; cl_audio_fluid in_fluid; } cl_audio_listener;
cl_result cl_audio_get_listener(cl_audio_listener* out); // the camera; in_fluid CL_FLUID_NONE / WATER / LAVA
bool cl_audio_hrtf_enabled(void); // Options > Directional Audio
Voice chat (API 1.7)
Simple Voice Chat streams (Celestial’s built-in client) are sounds: filters, sends, default routes (category CL_AUDIO_VOICE), volume, position and category reads all work on them (the position comes from the server: cl_sound_set_position is CL_E_UNSUPPORTED).
bool cl_voice_is_available(void); // connected to a voice chat server
bool cl_voice_is_muted(void);
bool cl_voice_is_disabled(void); // voice chat off or deafened
bool cl_voice_is_talking(cl_uuid speaker);
typedef struct cl_voice_monitor_opts { uint32_t struct_size; bool enabled, wet_only; float gain; cl_audio_bus bus; } cl_voice_monitor_opts;
cl_result cl_voice_monitor(const cl_voice_monitor_opts* opts); // your own microphone, while you transmit
The monitor plays your microphone back to you while you talk; with wet_only only through bus, so you hear your voice echo in a cave the way others hear it.
Pending: a server-side voice.server_packet event (a player’s loudness, for “monsters hear you” gameplay) needs a voice chat server in Celestial’s integrated / dedicated server, which doesn’t exist yet. cl_core_has("voice.server_packet") is false until then.
PCM streams (API 1.7)
typedef struct cl_audio_stream_opts { uint32_t struct_size; int32_t rate, channels; cl_vec3 pos; cl_sound_category category;
bool relative; float range, volume; } cl_audio_stream_opts; // CL_AUDIO_STREAM_OPTS_INIT
cl_result cl_audio_stream_open(const cl_audio_stream_opts* opts, cl_audio_stream* out);
cl_result cl_audio_stream_push(cl_audio_stream st, cl_str pcm); // signed 16-bit little-endian, channels interleaved
cl_result cl_audio_stream_queued(cl_audio_stream st, int32_t* out); // frames not played yet
cl_sound cl_audio_stream_sound(cl_audio_stream st);
cl_result cl_audio_stream_close(cl_audio_stream st);
Rate 8000…96000 Hz; 2 channels only for relative streams (OpenAL never pans stereo). At most 2 seconds are buffered (CL_E_LIMIT when full); keep queued above ~100 ms to avoid gaps. The category’s volume slider applies. Up to 32 streams per client. Uses: synthesizers, procedural engines, text-to-speech, voice changers.
Acoustic tracing (API 1.7)
cl_result cl_audio_trace_paths(cl_vec3 from, cl_vec3 to, const cl_audio_trace_opts* opts /* max_blocks = 48 */, cl_audio_trace* out);
cl_result cl_audio_trace_acoustics(cl_vec3 from, cl_vec3 to, cl_audio_acoustics* out);
cl_audio_trace_paths walks one straight line natively on the client thread (a few microseconds for 48 blocks) and returns the non-air blocks on it by acoustic material (stone, wood, wool, glass, dirt, sand, metal, snow, foliage, nether, other, water, lava), the first full block it hits, and the occlusion and direct gains of Sound Physics’ material model. cl_audio_trace_acoustics runs the full Sound Physics trace (reverb from the surroundings, occlusion, the opening a sound comes through) on a worker thread: the first call queues it and returns ready = false, the next tick’s call returns the result (kept for a second per pair of half-block cells). Both work with Sound Physics switched off.
Bindings
| C | C++ | Lua |
|---|---|---|
cl_sound_play(level, pos, s, cat, v, p) |
level.sound_play(pos, s, CL_SOUND_BLOCKS, v, p) |
cl.sound.play(level, pos, "my_mod:chime", "blocks", 1.0, 1.0), level:play(...) |
cl_sound_play_to_player(p, pos, s, cat, v, p) |
player.sound_play_to_player(pos, s, cat, v, p) |
cl.sound.play_to_player(player, pos, ...) |
cl_sound_play_at_entity(e, s, cat, v, p) |
entity.sound_play_at_entity(s, cat, v, p) |
cl.sound.play_at_entity(e, s, "players", 1.0, 1.0) |
cl_sound_play_local(pos, s, cat, v, p, loop, &h) |
clight::sound::play_local(...) → Expected<Sound> |
cl.sound.play_local(pos, s, "ambient", 1.0, 1.0, false) |
cl_sound_play_ui(s, v, p, &h) |
clight::sound::play_ui(s, v, p) |
cl.sound.play_ui("minecraft:ui.button.click", 1.0, 1.0) |
cl_music_play(s, replace) |
clight::sound::music_play(s, replace) |
cl.music.play(s, false) |
cl_sound_set_volume(s, v) |
sound.set_volume(v) |
s:set_volume(v) |
cl_sound_set_filter(s, &f) |
sound.set_filter(&f) |
s:set_filter{ type = "lowpass", gain = 0.6, gain_hf = 0.15 }, s:set_filter(nil) |
cl_audio_bus_create("reverb", &b) |
clight::audio::bus_create("reverb") → Expected<AudioBus> |
cl.audio.bus_create("reverb") |
cl_audio_bus_set(b, "decay_time", 3.2f) |
bus.set("decay_time", 3.2f) |
bus:set{ decay_time = 3.2, reflections_pan = cl.vec(0, 0, 1) } |
cl_sound_set_send(s, b, 0.5f, NULL) |
sound.set_send(bus, 0.5f, nullptr) |
s:set_send(bus, 0.5) |
cl_audio_route_default(b, 0.4f, cats, true, NULL) |
bus.route_default(0.4f, cats, true, nullptr) |
cl.audio.route_default(bus, 0.4, { categories = { "blocks", "hostile" } }) |
cl_audio_get_listener(&l) |
clight::audio::get_listener() |
cl.audio.listener() (in_fluid nil on land) |
cl_audio_stream_push(st, pcm) |
stream.push(bytes) |
st:push(string.pack(...)), st:push({ 0.5, -0.5, ... }) (numbers -1…1, or integers) |
cl_audio_trace_paths(a, b, &o, &t) |
clight::audio::trace_paths(a, b, &o) |
cl.audio.trace_paths(a, b, { max_blocks = 48 }) (adds by_material) |
cl_voice_monitor(&o) |
clight::voice::monitor(&o) |
cl.voice.monitor{ enabled = true, wet_only = true, gain = 0.5, bus = room } |
Lua categories are lower-case strings ("master", "music", "records", "weather", "blocks", "hostile", "neutral", "players", "ambient", "voice", "ui"). s:category() and s:distance_range() are the Lua spellings of get_category and range.
Feature detection: cl_core_has("audio"), "audio.bus_create" (any function), "audio.efx" (an EFX device), "audio.bus_output" (bus chaining), "audio.hrtf", "voice.chat", "voice.server_packet" (false: pending).
Background test instances (--background) use a null audio backend: sounds “play” (state and events work) but produce no output.
Cost
Nothing runs until a mod uses one of these calls: without mods, or with mods that don’t use buses, filters or sends, the engine applies no filter, effect slot or send (the vanilla audio path). Handle lookups are a hash map hit on the client thread; the smoothing runs on the sound thread at its 10 ms pace and allocates nothing per frame.
Planned
Registering sound events with a fixed range in the registry, a music selection event, and the server-side voice.server_packet event (see Voice chat).