Spatial audio API - SocketSVC Docs
SocketSVC Docs

Spatial audio API

Let plugins and mods create speakers, relay player microphones and play server audio

SocketSVC API v1 provides two kinds of speaker source: relaying a player's microphone, and server PCM/WAV/Opus audio. It supports fixed or moving positions, distance falloff, volume and recipient lists. Every speaker has its own channel UUID, decoder and buffers, so the same player can be relayed to several places and different files can play at the same time.

The design follows Simple Voice Chat's official LocationalAudioChannel, AudioChannel and AudioPlayer: position, distance, packet relaying and timed playback are well suited to being exposed directly to other plugins. This is SocketSVC's own API; it cannot load Simple Voice Chat plugins directly.

Servers can define a speaker's fixed ID and name; players adjust each speaker's volume at the top of the volume page, with the player list and skin heads below. Per-speaker volumes are saved per server. With these building blocks you can build lobby music, jukeboxes, PA announcements or ambient soundscapes as separate addon plugins, without touching SocketSVC's core.

Build and quick try

.\gradlew.bat buildAll
# or build only the Paper plugin, API and example
.\gradlew.bat :server:paper:build '-Ploaders=none'

Artifacts in server/paper/build/libs/ (<version> is server.version from gradle/socketsvc.properties):

JARPurpose
socketsvc-server-paper-<version>.jarInstall into Paper plugins/; contains WebSocket and native Opus
socketsvc-server-paper-<version>-api.jarFor compiling other plugins; do not install it or bundle it into other plugins
socketsvc-speaker-example-<version>.jarOptional example plugin, installed alongside the server JAR
socketsvc-bridge-paper-<version>.jarOptional Cross System Bridge, installed alongside the server JAR
socketsvc-server-paper-<version>-plain.jarNo dependencies; cannot replace the real server JAR

Update the server and the client for the matching Minecraft version, log in and connect to voice. With the example installed, an op looks at a block within six blocks and runs:

/ssvc-speaker mic PlayerName
/ssvc-speaker wav announcement.wav
/ssvc-speaker name Lobby PA
/ssvc-speaker stop
/ssvc-speaker remove
  • Put the WAV file into plugins/SocketSVCSpeakerExample/ first.
  • The example uses a 48-block range with linear fade starting at the source. Listen to a microphone relay with a second player; the source player does not hear their own echo.
  • The command permission is socketsvc.speaker.admin, default op. Both stop and remove close and remove that block's speaker.

The example's source bindings are temporary and do not create a new block type; sources must be bound again after a restart. config.yml stores the speaker namespace and custom names so the same block gets the same ID when recreated, which keeps players' adjusted volumes. Breaking the block or disabling the plugin closes the source. Piston movement, explosions, chunk unloads, region protection and full source persistence are left to real integrating plugins.

The example code lives in server/paper/src/example; every :server:paper:build compiles and packages it, but it is never mixed into the core plugin.

Plugin integration

Develop plugins with Java 21 or later and declare in plugin.yml:

depend: [SocketSVC]

Every release publishes the API jar to the update service's Maven repository (use the server.version your server runs; dev builds are published too, as 0.2.1-dev.7):

build.gradle.kts
repositories {
    maven("https://socketsvc-updater.vercel.app/maven") {
        name = "SocketSVC"
        content { includeGroup("com.socketsvc") }
    }
}

dependencies {
    compileOnly("com.socketsvc:socketsvc-server-paper-api:0.2.0")
    // Also add a compileOnly Paper API dependency as your plugin needs.
}
pom.xml
<repositories>
  <repository>
    <id>socketsvc</id>
    <url>https://socketsvc-updater.vercel.app/maven</url>
  </repository>
</repositories>

<dependencies>
  <dependency>
    <groupId>com.socketsvc</groupId>
    <artifactId>socketsvc-server-paper-api</artifactId>
    <version>0.2.0</version>
    <scope>provided</scope>
  </dependency>
</dependencies>

compileOnly(files("libs/socketsvc-server-paper-<version>-api.jar")) still works if you would rather vendor the jar.

Do not shade the API, and do not depend on com.socketsvc.server or client internals. Get the service from the Bukkit ServicesManager and create channels on the main thread:

SocketSvcApi api = getServer().getServicesManager().load(SocketSvcApi.class);
if (api == null) throw new IllegalStateException("SocketSVC is unavailable");
LocationalAudioChannel speaker = api.createLocationalChannel(
    this, // an enabled owner Plugin
    new SpatialPosition("world", 10.5, 65.5, 20.5),
    new SpatialSettings(48, 8, 0.8)); // range, fade start distance, volume 0..1
speaker.forwardMicrophone(player.getUniqueId());

Microphone relay

  • Relaying a microphone does not turn off the player's normal proximity voice.
  • The relay keeps the source player's UUID, so clients still apply that player's individual volume/mute, and spectator rules still apply.
  • The source player may be in another world; listeners must be in the speaker's world and within range.
  • forwardMicrophone(null) clears the source. No audio is produced while the player is offline; relaying resumes when the same UUID reconnects.

Playing a WAV from the server

speaker.forwardMicrophone(null);
AudioPlayer playback = speaker.play(PcmSources.wav(getDataFolder().toPath().resolve("music.wav")));
playback.completion().whenComplete((ignored, error) -> {
    if (error != null) getLogger().warning("Playback failed: " + error.getMessage());
    // To touch Bukkit worlds/players, schedule back onto the server main thread.
});
// Stop with playback.close() or speaker.stopPlayback(); after completion you can play again.
// When the block is removed / the feature is no longer used: speaker.close();

The WAV helper opens the file only on the playback worker and converts it to 48 kHz mono through Java Sound. PCM WAV is recommended; not every WAV encoding is guaranteed to convert. Errors are reported through completion(). It does not download URLs and ships no MP3/streaming music decoder.

Speaker names and per-player volume

Server plugins can use the newer overload to set a fixed UUID and display name:

// Read and reuse the same ID from plugin config or a database; do not randomUUID() on every start.
UUID speakerId = UUID.fromString("46cb6ec6-31e8-48cc-b432-7254b3c719a5");
LocationalAudioChannel lobby = api.createLocationalChannel(this, speakerId, "Lobby PA",
    new SpatialPosition("world", 10.5, 65.5, 20.5), SpatialSettings.defaults());
lobby.setDisplayName("Lobby Music"); // renaming does not change the ID or saved player volumes

Channels that exist at the same time must not share an ID. Names are 1–64 characters of plain text, without control characters or formatting codes, and not blank. The old three-argument overload still works: it generates a random ID and displays the owner plugin's name, which suits temporary sources that do not need volumes saved across restarts.

Client display and persistence

  • The client's volume page lists server-defined speakers at the top, with the player list and skin heads below. Both have 0–200% sliders; 0% mutes.
  • Speaker settings are saved in speakerVolumes in socketsvc.json, keyed by voice server endpoint and channel UUID. Offline/removed speakers are hidden from the list but their settings are kept. A different endpoint or ID counts as a different speaker. Settings are saved when the settings page is closed/left.
  • Effective playback volume is "distance falloff × server volume × client master volume × per-speaker volume"; for microphone relays it is additionally multiplied by the source player's individual volume, preserving player mutes. Normal proximity voice does not use the speaker slider.
  • Definitions, renames, removals and recipient changes appear in the UI at the next roster flush (usually within 10 ticks). Only speakers the player is allowed to receive are announced; they can be adjusted in advance even when not playing, in another world or out of range.
  • Roster sync requires both sides to also negotiate X-SocketSVC-Speakers: 1; mixed old/new setups without it keep ordinary spatial audio playback.

Controlling and extending sources

MethodBehaviour
updateLocation(position)Updates world and position; entities may be tracked from the main thread, but do not read Bukkit entities on the audio worker
setDisplayName(name)Updates the display name, keeping client volumes for the same ID
setSettings(settings)Updates range, fade start and volume; 0 <= fadeStart < range <= 1024, 0 <= volume <= 1
setRecipients(Set<UUID>)Copies a recipient allowlist; null means all eligible listeners, an empty set sends to nobody
sendOpus(bytes, startOfStream)Caller supplies 20 ms, 48 kHz mono Opus packets at 50 Hz, 1–1275 bytes each
play(PcmSource)Encodes Opus automatically and sends on a timer; if the worker falls behind it does not burst old frames
supportsSpatialAudio(playerUuid)Whether that player is currently connected and has negotiated spatial audio
close()Idempotently closes the channel, drops unsent packets and aborts playback

Custom PCM sources

PcmSource.readFrame(short[960]) fills 20 ms of 48 kHz mono signed 16-bit PCM; zero-pad the final partial frame and return false at EOF. This interface can wrap synthesized audio, decoders or your own sources without changing the transport protocol. Ownership of the source passes to SocketSVC only after play() succeeds; on EOF/error/stop the worker closes the source and encoder.

Source exclusivity and threading

A channel can have only one kind of source at a time: while PCM is playing you cannot send Opus directly or switch to a microphone, and while a microphone is bound you cannot play(). Stopping is non-blocking; to replay immediately, wait for the old handle's completion() before starting, and never wait for it on the Bukkit main thread. Callbacks may fire synchronously on the registering thread (if already complete) or on the playback worker, so always schedule Bukkit operations yourself.

Create channels on the main thread with an enabled owner; all other channel operations are thread-safe. Positions are immutable snapshots of world name and numbers, never retained Bukkit Location/Entity objects.

Resource limits

  • At most 32 channels and 32 playback workers server-wide.
  • Direct Opus has a budget of 60 packets per second with a burst of up to 60 packets; transport uses bounded queues.
  • A source's readFrame must complete promptly and respond to interrupts; SocketSVC does not forcibly kill uncooperative third-party code.
  • Clients keep at most 128 sources and clean up decoders idle for five seconds.

Lifecycle

EOF only releases the player, not the speaker, so it can be replayed; call close() when it is no longer needed. Disabling the owner or SocketSVC closes its sources automatically. Stopping and changing recipients can drop packets the server has not sent yet; audio already in network/client/sound card buffers may briefly finish playing, so a zero-latency cut is not guaranteed.

When /ssvc-server reload changes the listen address, the same ServicesManager API instance, channels and IDs are kept, so plugins do not need to re-register speakers. Audio is briefly interrupted while the listener switches and clients reconnect; playback workers neither pause nor restart files from the beginning. If the port cannot start, the original listener is restored; all channels are closed only when the plugin is actually disabled. See Hot reload.

Current spatial effects

The current spatial effect is 3D distance falloff + left/right stereo positioning updated with the player's facing. Height affects distance, but there is no HRTF up/down or front/back filtering, wall occlusion or reverb. Music sources respect the client master volume and deafen setting, and never impersonate players or create fake player nameplates.

Fabric/NeoForge/Forge server mod

Server mods provide SocketSvcModApi with the same capabilities, using a mod id instead of a Bukkit Plugin as the owner; channel, position and PCM types are exactly the same as on Paper. It has its own coordinate in the same repository, and one jar covers every loader and Minecraft version:

build.gradle.kts
dependencies {
    compileOnly("com.socketsvc:socketsvc-server-mod-api:0.2.0")
}
SocketSvcModApi api = SocketSvcModApi.get(); // null until the dedicated server has started and the listener is ready
if (api != null) {
    LocationalAudioChannel speaker = api.createLocationalChannel("mymod", UUID.randomUUID(), "Lobby",
        new SpatialPosition("minecraft:overworld", 0, 64, 0), SpatialSettings.defaults());
    speaker.play(PcmSources.wav(path));
}
  • World names are dimension IDs (for example minecraft:overworld), matching the player positions reported by the server mod; Paper uses Bukkit world names instead.
  • When the server stops, all channels close and get() returns null again; call closeOwner("mymod") when your mod releases its own resources.
  • There is currently no separately published API JAR for server mods; integrating mods can depend on the server mod JAR with compileOnly.

Voice mutes (API version 3)

From version 3, SocketSvcApi and SocketSvcModApi expose the server's voice mutes. Every method is safe to call from any thread and delegates straight to the registry the relay itself reads per frame, so an addon and the audio path cannot hold two different ideas of who is silenced.

// Two hours, with a reason the player is actually shown.
api.muteVoice(player, 2 * 60 * 60 * 1000L, "shouting in spawn", "AntiSpam");

// True for an individual mute OR a server-wide mute they are not exempt from.
// This is the question a chat or moderation addon actually wants.
boolean silenced = api.isVoiceMuted(player);

// The individual record, or null - including when they are silenced only by mute-all.
VoiceMuteInfo mute = api.voiceMute(player);

api.unmuteVoice(player);
api.setEveryoneVoiceMuted(true);
MethodAnswers
isVoiceMuted(UUID)Whether they may not talk right now, for any reason
voiceMute(UUID)The individual mute record, or null
voiceMutes()Every individual mute, longest-standing first
muteVoice(UUID, long, String, String)Issues or replaces one; 0 duration means until lifted
unmuteVoice(UUID)Lifts an individual mute; leaves mute-all alone
isEveryoneVoiceMuted() / setEveryoneVoiceMuted(boolean)The server-wide mute

A mute stops a player transmitting — microphone, soundboard, instrument and group audio alike — and never stops them hearing, so an addon building its own moderation UI on top of these does not have to think about the difference. Mutes are written to disk by SocketSVC itself and survive a restart; see Where mutes are stored.

muteVoice returns false when nothing changed — the player already held exactly that mute, or they hold socketsvc.mute.exempt. A reason is trimmed, capped at 200 characters and refused if it carries control characters, in which case nothing is muted at all rather than half-applied.

A reload swaps the listener, not the list

/ssvc-server reload builds a replacement listener and therefore a fresh registry, which reads the mute file back on the way up. A long-lived service reference keeps working across it; there is no need to re-look-up the API.

Client presence (API version 3)

Whether a player is running the client mod, answered from plugin-channel registration — the same question SocketSVC already asks before it sends them a handshake, and deliberately not "do they have a voice session", which also depends on the voice port being reachable from where they are.

VoiceClientState state = api.voiceClientState(player); // PRESENT, ABSENT or UNKNOWN
boolean hasMod = api.hasVoiceClient(player);           // PRESENT only
boolean enforced = api.requiresVoiceClient();          // require-client.enabled

Three states, and the third is the one that matters. Registration arrives shortly after a player joins rather than at the instant they do, so for a moment the honest answer is UNKNOWN. An addon that acts on ABSENT should say what it does about UNKNOWN rather than treating it as a no; hasVoiceClient collapses both to false, which is right for showing an icon and wrong for deciding whether to refuse somebody.

A player who is not online is UNKNOWN, never ABSENT — not being here is not evidence that they lacked the mod.

See Requiring the client mod for the server-side switch built on the same registry. It is off by default, and these methods answer whether or not it is on.

Microphone tap (API version 4)

From version 4, a plugin can receive players' voice as it arrives, rather than only place sound into the world. It is the hook behind a bridge to another voice mod, a Discord relay, server-side recording, transcription and moderation replay — one API for all five, so none of them has to be built separately.

This is the only SocketSVC API that reads a player rather than writing to one, and it is gated on three separate things.

The Cross System Bridge is a complete consumer to read alongside this section: it subscribes with Format.OPUS and forwards each frame untouched, and places the other direction with createLocationalChannel. Its source is in server/paper/src/bridge.

Off until you turn it on

microphone-tap.enabled is false in a fresh config. Every other switch in that file decides what SocketSVC does with audio; this one decides whether audio leaves SocketSVC at all, for code this project did not write. Turn it on because you installed something that needs it and you know what that something does with what it hears.

if (!api.isMicrophoneTapEnabled()) return; // hide your feature rather than letting it throw

MicrophoneTapHandle tap = api.subscribeMicrophone(this, MicrophoneTap.Format.PCM, audio -> {
    // The relay's network thread, 50 times a second per talking player. Do not block here,
    // and do not touch Bukkit's world or entity state - hand it to your own queue and leave.
    queue.offer(audio.speaker(), audio.pcm());
});

// Optional: SocketSVC closes it when your plugin is disabled and when the server stops.
tap.close();
MethodAnswers
isMicrophoneTapEnabled()Whether the operator switched microphone-tap.enabled on
subscribeMicrophone(owner, format, tap)Registers a tap; returns a handle you may close early
canTapMicrophone(UUID)Whether this player's audio may currently reach a tap

What gates it

GateEffect
microphone-tap.enabledOff by default. While off, subscribeMicrophone throws with a reason
socketsvc.bridgePer player, checked per frame. Defaults to true; take it from anyone whose voice should never leave
The relay's own checksA tap is fed past the mute check and past socketsvc.speak, so it can never hear what the room cannot

A refusal throws; it does not return silence. Unlike the group and mute methods, which answer emptily while their feature is off, a tap that quietly received no audio would be indistinguishable from a room where nobody is talking — so the switch being off, the owner being disabled and the subscription cap being reached each throw an IllegalStateException whose message says which.

socketsvc.bridge is checked on every frame rather than captured when you subscribe, against a permission answer SocketSVC refreshes every second, so revoking it stops that player's audio within a second rather than at their next reconnect. A player nobody has checked yet fails closed — the opposite of socketsvc.speak, because not having been checked is not consent.

Formats

Format.OPUS hands over the client's own 20 ms, 48 kHz mono frame untouched — costs nothing and preserves the original encoding, which is what anything forwarding audio on should want. Format.PCM additionally fills MicrophoneAudio.pcm() with 960 signed 16-bit samples. SocketSVC keeps one decoder per speaker and shares the result, so ten PCM subscribers cost one decode rather than ten.

MicrophoneAudio also carries stream(), sequence() and startOfStream(), so a consumer that keeps per-speaker state knows when a player stopped and started talking again. onSpeakerGone(UUID) fires when they disconnect or fall silent.

The arrays belong to SocketSVC

The same MicrophoneAudio is handed to every subscriber and to the relay's own spatial path in turn. Writing into opus() or pcm() corrupts what the next subscriber — and every listening player — receives. Copy before keeping either beyond the call.

What an operator sees

Every plugin that subscribes is named in the server log the first time it does, so an operator can find out what is listening by reading their log rather than by reading plugin source. /ssvc-server reload applies a change to the switch, and turning it back off closes every live subscription rather than leaving one running against an operator who has just said no.

A subscription survives /ssvc-server reload rebinding the port: the registry lives on the long-lived service alongside the channel registry, so a port change does not silently end somebody's tap.

Soundboard and instrument streams are not tapped in v1. They are separate streams with their own permission, and carrying them needs its own decision about the other systems' rate limits.

At most eight subscriptions may be live at once, for the same reason there is a channel ceiling: each one is another consumer of a path that runs 50 times a second per talking player.

Protocol and maintenance

The public API version is maintained separately from the Minecraft support matrix.

LocationContents
com.socketsvc.api in common/corePlatform-independent API types (LocationalAudioChannel, AudioPlayer, PcmSource, SpatialPosition, SpatialSettings, etc.)
server/paperThe Paper-specific entry point SocketSvcApi
SpatialAudioCoreChannel/playback worker implementation shared by Paper and server mods; clients only receive stable source/packet data

New Minecraft versions still go through gradle/socketsvc.properties and the shared compatibility layer; the API is never copied.

Protocol negotiation

The spatial capability is negotiated separately with X-SocketSVC-Spatial: 1 and requires audio v2 as well; see Voice protocol. Mixed old/new clients and servers keep ordinary proximity voice; clients that have not negotiated the capability receive no speaker packets. Reverse proxies must preserve the negotiation headers.

Extension directions

Moving speakers, area broadcasts and timed sound effects can be built directly on the existing position updates and PCM sources. Entity lifecycles, playlists and block storage belong in the integrating plugin. Before adding sound categories/separate client sliders, directional speakers, occlusion or reverb, write down concrete requirements and performance validation; anything that needs new client data gets a new capability version rather than rewriting existing packet formats.

API compatibility

Compatible small extensions should add new types or methods with default behaviour, avoiding new abstract methods on existing public interfaces. Breaking signatures/semantics bumps the API major version and keeps migration notes; do not treat API_VERSION as the Minecraft or wire protocol version. On release, update the server version, API artifact name examples and release notes together. When rolling the server back, also disable plugins that depend on the newer API to avoid missing services or classes.

Validation

  • SpatialAudioTest covers standalone sources, player attribution, distance/world/spectator/deafen/recipients, stream re-entry, owner cleanup, resource limits, WAV native Opus decoding and resource release on stop/exceptions.
  • VoiceProtocolTest verifies the new framing, unknown kinds and legacy protocol fallback.
  • Before a release, run buildAll and test with real players on every Minecraft version: head-turn positioning, distance falloff, two speakers playing at once, individual mute and reconnecting. Automated tests cannot replace in-game listening checks.