Sound
Verified against Minecraft 26.2 · A block breaks near you and you hear it: from
Level.playSoundon the server to an OpenAL source on the Sound engine thread.
Responsibility
The sound system turns events (“a stone block broke at this position”) into
audio (a decoded .ogg playing on an OpenAL source at a volume that falls
off with distance from the camera). It is entirely client-side: the server
never decodes, mixes or knows what a sound is beyond an Identifier and a
range. It is also the smallest complete system in the game — one resource
loader, one engine, one thread, one native library — which is why it is the
first system after Anatomy.
The one sentence a player recognises: the server says “play this here”, the client decides whether you can hear it.
The data it owns
SoundEvent(innet/minecraft/sounds, shared) is a record of anIdentifierand an optional fixed range.SoundEventsis the 2,000-line static registry of every one the game defines. It is a name, not a file.SoundSourceis the volume category —SoundSource.MASTER,SoundSource.MUSIC,SoundSource.BLOCKS,SoundSource.HOSTILE,SoundSource.PLAYERS,SoundSource.AMBIENT,SoundSource.VOICE,SoundSource.UI… — each an options slider.sounds.json, one per namespace in every resource pack, maps a sound event name to aSoundEventRegistration: a weighted list ofSoundentries (a file, or a redirect to another event — theSoundtype enum), each with volume, pitch, weight, attenuation distance, and whether to stream rather than load whole.SoundManagerowns the loaded form, a map ofIdentifier→WeighedSoundEvents, rebuilt on every resource reload.SoundInstance(client/resources/sounds) is one playing or wanting to play sound: event, source, volume, pitch, position, looping, relative, attenuation.SimpleSoundInstanceis a one-shot at a point;EntityBoundSoundInstancefollows an entity;TickableSoundInstancesubclasses (AbstractTickableSoundInstance, minecarts, elytra, bees, ambient loops) re-evaluate themselves every tick.SoundEngineowns the runtime state: which instances are playing (SoundEngine.instanceToChannel), grouped by source (SoundEngine.instanceBySource), delayed (SoundEngine.queuedSounds) and ticking (SoundEngine.tickingSounds); the per-category gains; theSoundBufferLibrarycache of decoded buffers; and theLibrary, which owns the OpenAL device, context and the source pools.com/mojang/blaze3d/audiois the OpenAL wrapper:Library(device, context, listener, channel pools),Channel(one OpenAL source),SoundBuffer(one OpenAL buffer),Listener(the ear — position and orientation), and aDeviceTrackerthat notices headphones being unplugged.
Nothing outside client/sounds touches OpenAL. Everything else calls
SoundManager.play and forgets.
When it runs
Four threads take part, and the page is mostly about which does what.
- Server thread: decides a sound happens (
ServerLevel.playSeededSound), computes who is in range, sends packets. Never audio. - Render thread (the client game thread): receives the packet, builds a
SoundInstance, callsSoundManager.play→SoundEngine.play. Once per client tickMinecraft.tickcallsSoundManager.tick, which walks the ticking sounds, updates positions and volumes, and expires finished channels; once per frameMinecraft.runTickcallsSoundManager.updateSourcewith the camera so the listener moves smoothly.MusicManager.tickalso runs here, choosing and fading background music. - Sound engine thread: a
SoundEngineExecutor, which is aBlockableEventLoop— the same event-loop pattern as the server thread — wrapped around a single daemon thread named “Sound engine”. Every OpenAL call is a task on this executor, submitted throughChannelAccess. The Render thread never calls OpenAL itself. Util.nonCriticalIoPool(the “Download-” threads): reads and decodes.oggfiles (JOrbisAudioStream, a Java Vorbis decoder) into aSoundBuffer, insideSoundBufferLibrary.getCompleteBuffer; the result is aCompletableFuturethat, on completion, schedules the “attach and play” task onto the sound thread.
The trace: a block breaks and you hear it
sequenceDiagram
participant SL as ServerLevel (Server thread)
participant PL as PlayerList
participant CPL as ClientPacketListener (Render thread)
participant CL as ClientLevel
participant SM as SoundManager
participant SE as SoundEngine
participant SBL as SoundBufferLibrary (IO pool)
participant CA as ChannelAccess (Sound engine thread)
participant Lib as Library / Channel (OpenAL)
SL->>SL: playSeededSound(except, x,y,z, SoundEvent, SoundSource, volume, pitch, seed)
SL->>PL: broadcast(except, x,y,z, SoundEvent.getRange(volume), dimension, ClientboundSoundPacket)
PL-->>CPL: ClientboundSoundPacket — to every player within range, except one
CPL->>CL: handleSoundEvent → playSeededSound (after ensureRunningOnSameThread)
CL->>SM: play(SimpleSoundInstance) — seeded, so every client picks the same variant
SM->>SE: play(instance)
SE->>SE: resolve WeighedSoundEvents, pick a Sound by weight, calculateVolume × category gain
SE->>CA: createHandle(STATIC or STREAMING pool) — a task on the sound thread
CA->>Lib: acquireChannel — an OpenAL source from the pool, or null if all 30 are busy
SE->>CA: handle.execute — setPitch, setVolume, linearAttenuation(range), setSelfPosition
SE->>SBL: getCompleteBuffer(path) — decode the .ogg on the IO pool (cached)
SBL-->>CA: thenAccept → handle.execute — attachStaticBuffer, play
loop every client tick
SE->>CA: scheduleTick — pump streams, release stopped channels
end
Narrated:
- The server picks who hears it.
ServerLevel.playSeededSoundasksSoundEvent.getRangefor the audible radius — a fixed range if the event declares one, otherwise 16 blocks scaled up by volumes above 1 — andPlayerList.broadcastsendsClientboundSoundPacketto every player in that dimension within range, skipping the except player. The seed travels in the packet so that all clients pick the same random variant and pitch. - The client receives it on the game thread.
ClientPacketListener.handleSoundEventgoes throughPacketUtils.ensureRunningOnSameThread(see Anatomy) and callsClientLevel.playSeededSound, which builds aSimpleSoundInstanceand hands it toSoundManager.play. SoundEngine.playresolves the name to a file. The instance’sIdentifieris looked up in theSoundManagerregistry to get aWeighedSoundEvents;WeighedSoundEvents.getSoundrolls the weighted choice (following event-to-event redirects) to a concreteSound; volume is multiplied by the category gain and the master gain; a zero volume returns early with not started rather than occupying a channel. Any registeredSoundEventListeneris told first —SubtitleOverlayis the only one, which is how subtitles work.- A channel is borrowed on the sound thread.
ChannelAccess.createHandleposts a task to theSoundEngineExecutor; on that threadLibraryacquires aChannelfrom the static or streaming pool (chosen bySound.shouldStream). The Render thread blocks on that future — the one place the game thread waits on the sound thread — and gets a channel handle, or null when every source is in use, in which case the sound is silently dropped. - Parameters are set, then the data arrives later. The handle’s
ChannelAccess.executeposts the pitch/volume/attenuation/position setup; separately,SoundBufferLibrary.getCompleteBufferreturns a future for the decoded buffer (cached per path; decoding runs onUtil.nonCriticalIoPool). When the buffer is ready its continuation posts “attach buffer, play” to the sound thread. A sound therefore starts one or two frames after the packet, never on the frame it arrives, unless the buffer is already cached — which is whatSound.shouldPreloadandSoundEngine.requestPreloadare for. - Streams are pumped by the tick. Long sounds (music, records) are
streamed:
Channel.attachBufferStreamqueues a few seconds of decoded audio, andChannelAccess.scheduleTick, posted once per client tick fromSoundEngine.tick, callsChannel.updateStreamon each to refill. The same pass releases channels whose source reports stopped. - The listener follows the camera.
SoundEngine.updateSourceposts aListenerTransform(position, forward, up) from theCamerato the sound thread every frame; OpenAL does the distance attenuation and panning from there.SoundInstance.isRelativesounds (UI clicks) are positioned relative to the listener instead, so they never attenuate.
Interfaces
- Called by: anything with a
Level— blocks, entities, items — throughLevel.playSound;PlaySoundCommand;MusicManagerfor music; the ambient handlers inclient/resources/sounds(BiomeAmbientSoundsHandler,UnderwaterAmbientSoundHandler,BubbleColumnAmbientSoundHandler) for loops that exist only on the client. - Calls into:
com/mojang/blaze3d/audio→ LWJGL’s OpenAL bindings;JOrbisAudioStream→ JOrbis for Vorbis decoding; the resource system forsounds.jsonand the.oggfiles. - Crosses the network as:
ClientboundSoundPacket(a point in space),ClientboundSoundEntityPacket(attached to an entity, handled byClientPacketListener.handleSoundEntityEvent),ClientboundStopSoundPacket(/stopsound). All clientbound. There is no serverbound sound packet: the server infers what you did from other packets and tells others about the sound. - Data-driven by:
sounds.json(resource packs, so the client’s own), theRegistries.SOUND_EVENTregistry (static; data packs cannot add sound events, only reference them), andoptions.txtfor category volumes, device, and HRTF.
Invariants and surprises
- Your own sounds are predicted, not received.
Player.playSoundcallsLevel.playSoundwith itself as the except entity. On the server that broadcasts to everyone but you; on the client,ClientLevel.playSeededSoundseesexcept == Minecraft.playerand plays it locally at once. So the sound of your own footsteps, hits and block breaks never round-trips — and a laggy connection delays what you hear of others, never of yourself.LocalPlayer.playSoundgoes further and callsClientLevel.playLocalSounddirectly. - The sound thread is an event loop, not a mixer.
SoundEngineExecutordoes nothing but run tasks; OpenAL (the native library) does the mixing on its own threads. The Java thread exists only so that AL calls are serialised and never made from the Render thread. - Thirty sources, split by square root.
Libraryasks the device how many mono sources it offers (default 30), gives clamp(√n, 2, 8) of them to the streaming pool and the rest to the static pool. When a pool is empty new sounds are dropped, not queued; the game does not steal channels by priority. - Volume zero is not played.
SoundEngine.playreturns not started for a computed volume of 0 unless the instance saysSoundInstance.canStartSilent— which is why ticking sounds that fade in must opt in, and why muting a category frees its channels rather than playing silence. - Looping is two mechanisms. Static sounds loop in OpenAL
(
Channel.setLooping); streamed sounds loop by wrapping the decoder in aLoopingAudioStream, since the source only ever holds a few seconds. - Attenuation is linear and per-sound.
SoundInstance.getAttenuationis linear or none; the distance isSound.getAttenuationDistance(16 by default, fromsounds.json) scaled by volume. The server’s range and the client’s attenuation are computed separately from the same numbers — a sound can be sent and inaudible, or (with a resource pack) audible beyond where the server would send it. - Reload is destroy-and-rebuild.
SoundManager.reloadandSoundEngine.reloadstop everything, tear down the OpenAL context inLibrary.cleanup, andSoundEngine.loadLibraryagain. Changing the output device in options, or theDeviceTrackernoticing a device change, takes the same path — so sounds cut out for a moment when you plug in headphones.
Where to look
SoundManager · SoundEngine · SoundInstance · SimpleSoundInstance ·
EntityBoundSoundInstance · WeighedSoundEvents · Sound ·
SoundEngineExecutor · ChannelAccess · SoundBufferLibrary ·
JOrbisAudioStream · Library · Channel · Listener · MusicManager ·
ServerLevel.playSeededSound · PlayerList.broadcast ·
ClientboundSoundPacket