AAA-competitive spatial audio engine for the Dynamis World Engine. Simulation-first, designer-controllable, physically coherent. Built on JDK 25 with Loom virtual threads and Panama FFM.
dynamis-audio-api ← interfaces, constants, value types (no dependencies)
↑
dynamis-audio-simulation ← ray backends, reverb estimators, fingerprint builders
↑
dynamis-audio-core ← emitter lifecycle, voice manager, snapshot manager
↑
dynamis-audio-dsp ← mixer, bus graph, DSP nodes, voice chain
↑
dynamis-audio-designer ← RTPC, mix snapshots, hot reload, fingerprint registry
↑
dynamis-audio-test-harness ← integration tests only (never shipped)
Dependency direction is strictly upward. No module may import from a module
above it in this list. API seam interfaces (MixBusControl,
EarlyReflectionSink, VoiceCompletionListener) break any potential
cross-layer cycles at the api boundary.
External dependencies:
io.vectrix:vectrix— SIMD-optimised math (all modules)io.dynamis:meshforge— mesh processing (simulation,provided)org.dynamisphysics:dynamis-physics-api— BVH ray cast (simulation,provided)
The DSP render thread — SoftwareMixer.renderBlock() and every method it
calls — must never allocate on the heap during steady-state operation.
Enforced by CI:
AllocationEnforcementTestinstruments the render path and fails if any allocation escapes afterprepare().
Rules:
- All buffers sized in
prepare(maxFrameCount, channels), never inprocessInternal(). VarHandleacquire/release for snapshot and parameter double-buffers.- Pre-allocated ping-pong buffers in
VoiceNode— two buffers, alternating per DSP stage. - Loom virtual threads handle emitter lifecycle and ray queries off the render thread.
AcousticEventQueueImplis a lock-free ring buffer; drain is called once perrenderBlock().
Permitted off render-thread:
AcousticFingerprintBuilder.build()— called at room entry.AcousticWorldProxyBuilder.build()/buildFromPhysicsMesh()— called onGeometryDestroyedEvent.VoicePoolconstruction — called once at engine startup.ResamplingAudioAssetconstructor — allocates intermediate buffer once at asset bind time.
Requires JDK 25, Maven 3.9+.
mvn -pl dynamis-audio-test-harness -am testmvn -pl dynamis-audio-api -am compile
mvn -pl dynamis-audio-simulation -am compile
mvn -pl dynamis-audio-core -am compile
mvn -pl dynamis-audio-dsp -am compile
mvn -pl dynamis-audio-designer -am compile
mvn -pl dynamis-audio-test-harness -am testmvn -pl dynamis-audio-test-harness -am test \
-Ddynamis.audio.raybackend=brute| Phase | Count |
|---|---|
| 0 | 87 |
| 1 | 188 |
| 2 | 248 |
| 3 | 292 |
| 4 | 319 |
| 5 | 338 |
| 6 | 358 |
| 7 | 377 |
The following wiring is required in the game engine repo before audio plays.
// Construct in dependency order
AcousticSnapshotManager acousticMgr = new AcousticSnapshotManager();
AcousticEventQueueImpl eventQueue = new AcousticEventQueueImpl(256);
MixSnapshotManager mixMgr = new MixSnapshotManager();
AudioDevice device = AudioDeviceFactory.create(sampleRate, 2, blockSize);
SoftwareMixer mixer = new SoftwareMixer(acousticMgr, eventQueue, device, mixMgr);
VoiceManager voiceMgr = new VoiceManager(
AcousticConstants.DEFAULT_PHYSICAL_BUDGET,
AcousticConstants.DEFAULT_CRITICAL_BUDGET);
// Wire cross-references
mixer.setVoiceManager(voiceMgr);
voiceMgr.setVoicePoolCapacity(mixer.getVoicePool().capacity());
// Add SchroederReverbNode or FingerprintDrivenReverbNode to reverb bus
FingerprintDrivenReverbNode reverbNode = new FingerprintDrivenReverbNode("main-reverb");
mixer.getReverbBus().addEffect(reverbNode);// Build proxy from physics mesh — call once per level load or on GeometryDestroyedEvent
AcousticWorldProxy proxy = new AcousticWorldProxyBuilder()
.buildFromPhysicsMesh(physicsWorld, (bodyId, triIdx,
ax, ay, az,
bx, by, bz,
cx, cy, cz) -> {
// Map physics material/layer to acoustic surface metadata
// Return null to skip non-acoustic surfaces (triggers, sensors, etc.)
return myGameAcousticTagger.tag(bodyId, triIdx,
ax, ay, az, bx, by, bz, cx, cy, cz);
});
// Publish to audio snapshot — game thread
AcousticWorldSnapshotImpl back = acousticMgr.acquireBackBuffer();
back.setRayQueryBackend(
AcousticRayQueryBackendFactory.create(physicsWorld, proxy));
acousticMgr.publish();Triangle ordering contract: buildFromPhysicsMesh() enumerates triangles
in physics iteration order. DynamisCollisionRayBackend uses
RaycastResult.layer() as the proxy triangle index. Both must use the same
ordering — do not sort or reindex triangles between build and backend
construction.
// Game thread: update listener position on all active emitters
for (LogicalEmitter emitter : voiceManager.emitters()) {
emitter.setListenerPosition(listener.x, listener.y, listener.z);
}
// Game thread: post acoustic events (portal state changes, geometry updates)
eventQueue.post(new PortalStateChanged(portalId, newAperture));
// DSP thread (dedicated thread or engine audio callback):
mixer.renderBlock(); // drives everything — events, snapshots, voice chain// Load asset (pre-converted to 48kHz — PHASE 7: resampling handles other rates)
PcmAudioAsset asset = new PcmAudioAsset(myDecoder.decodeToFloat(), 2);
// Trigger via EventSystem (handles emitter creation, RTPC, voice registration)
EventSystem events = new EventSystem(voiceManager);
events.registerEvent(new SoundEventDef.Builder("explosion")
.gain(0.9f).looping(false).build());
LogicalEmitter emitter = events.trigger("explosion", x, y, z);
// Bind asset to the voice once promoted to PHYSICAL
// (VoiceManager promotion is automatic — asset binding is your responsibility)
VoiceNode voice = mixer.getVoicePool().voices()[emitter.voiceIndex()];
voice.setAsset(asset);-
PhysicsSurfaceTaggerimplemented and tested with your material ID scheme -
AcousticRoomimplementations registered in snapshot for all rooms (providevolumeMeters3(),surfaceAreaMeters2(),totalAbsorption(band)) -
AcousticFingerprintBuildercalled on room entry; fingerprints registered inAcousticFingerprintRegistry -
FingerprintDrivenReverbNode.setFingerprint()called when listener changes room -
AcousticFingerprintRegistry.unregister(roomId)called before proxy rebuild onGeometryDestroyedEvent -
VoiceManager.setVoicePoolCapacity()called after mixer construction - Audio device opened before first
mixer.renderBlock()call - All
PcmAudioAssetsources pre-decoded to 48kHz stereo float PCM (or rely onResamplingAudioAssetwrapping for other rates) -
generateStubTone()is removed — confirm voices render silence without a bound asset, not a test tone
Full design decisions, rationale, and per-phase constraints are in:
docs/architecture_record.md
Read this before making any changes to the snapshot double-buffer pattern, the DSP node lifecycle, the module dependency graph, or the voice promotion model. Every non-obvious decision is documented there with the reasoning that would otherwise require reconstructing from git history.