Sound API / New Sound API

Sounds goes silence after some conditions or behavior I caused? More details look at below:

Scripts:

_G.RX_STOP = true task.wait(0.3) _G.RX_STOP = false local ID = "rbxassetid://8169240213" local RPM, INTERVAL = 720, 60/720 local folder = workspace.Terrain:FindFirstChild("RXLegacy") if folder then folder:Destroy() end folder = Instance.new("Folder") folder.Name = "RXLegacy" folder.Parent = workspace.Terrain local part = Instance.new("Part") part.Anchored = true part.CanCollide = false part.Transparency = 1 part.Size = Vector3.one part.Position = Vector3.zero part.Parent = folder local n = 0 task.spawn(function() while not _G.RX_STOP do n += 1 local s = Instance.new("Sound") s.SoundId = ID s.Volume = 0.5 s.RollOffMaxDistance = 2000 s.RollOffMinDistance = 40 s.Parent = part s:Play() s.Ended:Connect(function() s:Destroy() end) task.delay(15, function() if s.Parent then s:Destroy() end end) task.wait(INTERVAL) end folder:Destroy() print("[RXLegacy] stopped after " .. n .. " plays") end) print("[RXLegacy] LEGACY Sound API, same asset & rate — does this stutter/go silent too?")

_G.RX_STOP = true task.wait(0.2) _G.RX_STOP = false local ID = “rbxassetid://129597576449946” local RPM = 720 local INTERVAL = 60/RPM local folder = workspace.Terrain:FindFirstChild(“RXTest”) if folder then folder:Destroy() end folder = Instance.new(“Folder”) folder.Name = “RXTest” folder.Parent = workspace.Terrain local n = 0 task.spawn(function() while not _G.RX_STOP do n += 1 local att = Instance.new(“Attachment”) att.Parent = folder local p = Instance.new(“AudioPlayer”) p.AssetId = ID p.Volume = 0.5 p.Parent = att local em = Instance.new(“AudioEmitter”) em.Parent = att local w = Instance.new(“Wire”) w.SourceInstance = p w.SourceName = “Output” w.TargetInstance = em w.TargetName = “Input” w.Parent = att p:Play() p.Ended:Connect(function() att:Destroy() end) task.delay(15, function() if att.Parent then att:Destroy() end end) task.wait(INTERVAL) end folder:Destroy() print(“[RXTest] stopped after " .. n .. " shots”) end) print(“[RXTest] DIFFERENT ASSET — fire your gun and listen to whether this one survives”)

So I used sound legacy, and new sound API. It seems like no matter what I did, it goes silence. After rapid firing it goes silence.

Expected behavior

I expect all sounds to be audible still.

I found the issue , but not the solution.
Both legacy and new audio API has the same issue when it’s wired with runtime reverb.
But plain audio works correctly

1 Like

Still no fixes. Where are Roblox staffs lol

Hey @RobloxStudioLife5 – I tried the provided scripts, and added some reverb via ReverbSoundEffect and AudioReverb, but I was not able to reproduce this behavior.

Do you have an estimate how many Sounds/AudioPlayers are trying to play (i.e. IsPlaying == true) when the dropouts occur?

It is under 50, It only occurs when I wired a runtime reverb.

I’m surprised this post hasn’t gotten more attention, myself and several other players experience this exact same effect in multiple other games, most prominently in our case Deepwoken.
It’s not every game, but its more than just one. I don’t have the scripts to share reasoning, but I can at least confirm it’s not just you.

Can you share any Microprofile captures from during the dropouts? Perhaps something is stalling the audio engine, and we’d see a function that’s normally quick taking a really long time.

Maybe not directly related to the bug report, these scripts are framerate-dependent, since

task.wait(INTERVAL)

waits at least INTERVAL seconds – the script will resume on the next frame after the elapsed wait-time. That means any really good lag spike could delay audio playback.

You can pre-schedule playback with the SoundService:GetMixerTime() method to decouple audio playback from the framerate – the reworked AudioPlayer script might look something like

_G.RX_STOP = true 
task.wait(0.2) 
_G.RX_STOP = false 
local ID = "rbxassetid://129597576449946"
local RPM = 720 
local INTERVAL = 60/RPM 
local folder = workspace.Terrain:FindFirstChild("RXTest") 
if folder then folder:Destroy() end 
folder = Instance.new("Folder") 
folder.Name = "RXTest"
folder.Parent = workspace.Terrain 
local n = 0 

local SoundService = game:GetService("SoundService")
local planAhead = 0.5

task.spawn(function() 
	local beginTime = SoundService:GetMixerTime()
	while not _G.RX_STOP do 
		n += 1 
		local att = Instance.new("Attachment") 
		att.Parent = folder 
		local p = Instance.new("AudioPlayer") 
		p.Asset = ID 
		p.Volume = 0.5 
		p.Parent = att 
		local em = Instance.new("AudioEmitter") 
		em.Parent = att 
		local w = Instance.new("Wire") 
		w.SourceInstance = p 
		w.TargetInstance = em 
		w.Parent = att 

		local scheduledTime = beginTime + INTERVAL * n

		p.Ended:Connect(function() att:Destroy() end) 
		p:Play(scheduledTime) 

		while scheduledTime > SoundService:GetMixerTime() + planAhead do 
			task.wait() 
		end 
	end 
	folder:Destroy() 
	print("[RXTest] stopped after " .. n .. " shots") 
end) 
print("[RXTest] DIFFERENT ASSET — fire your gun and listen to whether this one survives")

Yes there is , Before Reverb:

After Reverb:

Regarding the sound engine I made, it’s a custom sound engine that handles reverb. This is how it wires it:

--!strict
--!native
--!optimize 2

local MAX_PER_PRESET = 48

local ROOM_REVERB_ENABLED = true

export type PoolEntry = {
	Attachment : Attachment,
	Player     : AudioPlayer,
	Emitter    : AudioEmitter,
	Eq         : AudioEqualizer,
	PlayerWire : Wire,
	EqWire     : Wire,
	Reverb     : AudioReverb?,
	ReverbWire : Wire?,
	Release    : (() -> ())?,
}

export type SoundPool = typeof(setmetatable({} :: {
	_pools     : { [string]: { PoolEntry } },
	_destroyed : boolean,
	_reverb    : Instance?,
}, {} :: { __index: any }))

local SoundPool   = {}
SoundPool.__index = SoundPool
SoundPool.__type  = "SoundPool"

function SoundPool.new(reverb: Instance?): SoundPool
	return setmetatable({
		_pools     = {} :: { [string]: { PoolEntry } },
		_destroyed = false,
		_reverb    = reverb,
		_reverbs   = {} :: { AudioReverb },
	}, SoundPool) :: any
end

function SoundPool.ApplyRoom(self: SoundPool, apply: (AudioReverb) -> ())
	local reverbs = self._reverbs
	local count   = #reverbs
	local live    = 0

	for i = 1, count do
		local reverb = reverbs[i]
		if reverb.Parent ~= nil then
			live += 1
			reverbs[live] = reverb
			apply(reverb)
		end
	end

	for i = count, live + 1, -1 do
		reverbs[i] = nil
	end
end

function SoundPool.CountReverbs(self: SoundPool): number
	return #self._reverbs
end

local CURVE_SAMPLES = 12

local function ApplyMaxDistance(emitter: AudioEmitter, maxDistance: number, minDistance: number?)
	local near  = math.max(minDistance or 10, 0.1)
	local far   = math.max(maxDistance, near * 2)
	local curve = { [0] = 1, [near] = 1 }

	local ratio = far / near
	for i = 1, CURVE_SAMPLES do
		local distance = near * ratio ^ (i / CURVE_SAMPLES)
		curve[distance] = near / distance
	end
	curve[far] = 0

	emitter:SetDistanceAttenuation(curve)
end

local function Connect(parent: Instance, name: string, source: Instance, target: Instance): Wire
	local wire = Instance.new("Wire")
	wire.Name           = name
	wire.SourceInstance = source
	wire.SourceName     = "Output"
	wire.TargetInstance = target
	wire.TargetName     = "Input"
	wire.Parent         = parent
	return wire
end

local function InsertReverbBeforeEmitter(attachment: Attachment, emitter: AudioEmitter): (AudioReverb?, Wire?)
	local feed: Wire? = nil
	local dangling: Wire? = nil
	for _, child in attachment:GetDescendants() do
		if child:IsA("Wire") then
			if child.TargetInstance == emitter then
				feed = child
				break
			elseif not child.Connected and child.SourceInstance ~= nil then
				local target = child.TargetInstance
				if target == nil or target.Parent == nil then
					dangling = dangling or child
				end
			end
		end
	end

	if not feed and dangling then
		warn(string.format(
			"SoundPool: '%s' has a Wire ('%s') whose target no longer exists — its authored chain "
				.. "never reached the AudioEmitter and the sound would be silent. Reconnecting it to the emitter.",
			attachment:GetFullName(),
			dangling.Name
		))
		dangling.TargetInstance = emitter
		dangling.TargetName     = "Input"
		feed = dangling
	end

	if not feed then return nil, nil end

	local tail = feed.SourceInstance
	if not tail then return nil, nil end

	local reverb = Instance.new("AudioReverb")
	reverb.Name      = "ResonixRoomReverb"
	reverb.DecayTime = 0.01
	reverb.Density   = 0
	reverb.Diffusion = 0
	reverb.WetLevel  = -80
	reverb.Parent    = attachment

	feed.TargetInstance = reverb
	feed.Name           = "ResonixTailToReverb"

	local outWire = Connect(attachment, "ResonixReverbToEmitter", reverb, emitter)

	return reverb, outWire
end

local function CreateEntryFromTemplate(config: any, reverb: Instance?): PoolEntry
	local attachment = Instance.new("Attachment")
	attachment.Name  = "ResonixAudioEmitter"
	attachment.Parent = workspace.Terrain

	local clone = (config.Template :: Instance):Clone()
	local player  = clone:FindFirstChildWhichIsA("AudioPlayer", true) :: AudioPlayer
	local emitter = clone:FindFirstChildWhichIsA("AudioEmitter", true) :: AudioEmitter
	local eq      = clone:FindFirstChildWhichIsA("AudioEqualizer", true) :: AudioEqualizer?
	assert(player, "SoundPool: Template is missing an AudioPlayer")
	assert(emitter, "SoundPool: Template is missing an AudioEmitter")

	for _, child in clone:GetChildren() do
		child.Parent = attachment
	end
	clone:Destroy()

	if config.MaxDistance ~= nil then
		ApplyMaxDistance(emitter, config.MaxDistance, config.MinDistance)
	end
	if config.Volume ~= nil then
		player.Volume = config.Volume
	end
	player.Looping = false

	local entryReverb, reverbWire = nil, nil
	if reverb and ROOM_REVERB_ENABLED then
		entryReverb, reverbWire = InsertReverbBeforeEmitter(attachment, emitter)
	end

	if #emitter:GetConnectedWires("Input") == 0 then
		warn(string.format(
			"SoundPool: '%s' AudioEmitter has no inbound Wire — its Template's chain is broken "
				.. "and this sound will be silent.",
			attachment:GetFullName()
		))
	end

	if not eq then
		warn(string.format(
			"SoundPool: '%s' Template has no AudioEqualizer — adding one so material "
				.. "transmission filtering has somewhere to land.",
			attachment:GetFullName()
		))
		local added    = Instance.new("AudioEqualizer")
		added.Name     = "ResonixAudioEqualizer"
		added.LowGain  = 0
		added.MidGain  = 0
		added.HighGain = 0
		added.Parent   = attachment
		eq = added
	end

	return {
		Attachment = attachment,
		Player     = player,
		Emitter    = emitter,
		Eq         = eq :: any,
		PlayerWire = nil :: any,
		EqWire     = nil :: any,
		Reverb     = entryReverb,
		ReverbWire = reverbWire,
	}
end

local function CreateEntry(config: any, reverb: Instance?): PoolEntry
	if config.Template then
		return CreateEntryFromTemplate(config, reverb)
	end

	local attachment  = Instance.new("Attachment")
	attachment.Name   = "ResonixAudioEmitter"
	attachment.Parent = workspace.Terrain

	local player = Instance.new("AudioPlayer")
	player.Name  = "ResonixAudioPlayer"
	if config.AudioPlayer then
		player.AssetId = config.AudioPlayer.AssetId
	else
		player.AssetId = config.SoundId or ""
	end
	player.Looping = false
	player.Parent  = attachment

	local emitter = Instance.new("AudioEmitter")
	emitter.Name  = "ResonixAudioEmitter"
	ApplyMaxDistance(emitter, config.MaxDistance or 100, config.MinDistance)
	emitter.Parent = attachment

	local eq    = Instance.new("AudioEqualizer")
	eq.Name     = "ResonixAudioEqualizer"
	eq.LowGain  = 0
	eq.MidGain  = 0
	eq.HighGain = 0
	eq.Parent   = attachment

	local playerWire = Connect(attachment, "ResonixPlayerToEq", player, eq)
	local eqWire     = Connect(attachment, "ResonixEqToEmitter", eq, emitter)

	local entryReverb, reverbWire = nil, nil
	if reverb and ROOM_REVERB_ENABLED then
		entryReverb, reverbWire = InsertReverbBeforeEmitter(attachment, emitter)
	end

	if config.Volume ~= nil then
		player.Volume = config.Volume
	else
		player.Volume = 1
	end

	return {
		Attachment = attachment,
		Player     = player,
		Emitter    = emitter,
		Eq         = eq,
		PlayerWire = playerWire,
		EqWire     = eqWire,
		Reverb     = entryReverb,
		ReverbWire = reverbWire,
	}
end

function SoundPool.Acquire(
	self      : SoundPool,
	presetKey : string,
	config    : any,
	position  : Vector3
): PoolEntry
	local pool = self._pools[presetKey]

	local entry: PoolEntry
	if pool and #pool > 0 then
		entry = table.remove(pool) :: PoolEntry
	else
		entry = CreateEntry(config, self._reverb)
		if entry.Reverb then
			table.insert(self._reverbs, entry.Reverb)
		end
	end

	entry.Attachment.WorldPosition = position
	entry.Attachment.Parent        = workspace.Terrain

	entry.Release = nil

	local rp = config.RandomPitch
	if rp then
		entry.Player.PlaybackSpeed = rp.Min + math.random() * (rp.Max - rp.Min)
	else
		entry.Player.PlaybackSpeed = config.PlaybackSpeed or 1
	end

	return entry
end

function SoundPool.Release(
	self      : SoundPool,
	presetKey : string,
	entry     : PoolEntry
)
	if self._destroyed then
		entry.Attachment:Destroy()
		return
	end

	local pool = self._pools[presetKey]
	if not pool then
		pool = {}
		self._pools[presetKey] = pool
	end

	for _, pooled in pool do
		if pooled == entry then
			return
		end
	end

	if #pool < MAX_PER_PRESET then
		table.insert(pool, entry)
	else
		entry.Attachment:Destroy()
	end
end

function SoundPool.Destroy(self: SoundPool)
	if self._destroyed then return end
	self._destroyed = true

	for _, pool in self._pools do
		for _, entry in pool do
			entry.Attachment:Destroy()
		end
	end

	table.clear(self._pools)
	table.clear(self._reverbs)
end

return SoundPool

I only switched on and off this variable by the way in both videos.

local ROOM_REVERB_ENABLED = true

In Addition, This is what happens on Reverb + MixerTime

Code:

--!strict
--!native
--!optimize 2



local RunService = game:GetService("RunService")
local Players     = game:GetService("Players")
local SoundService = game:GetService("SoundService")
assert(RunService:IsClient(), "ResonixAudio.Client must only be required on the client")


local AudioPresets      = require(script.Parent.AudioPresets)
local SoundPool         = require(script.Parent.SoundPool)
local AcousticEstimator = require(script.Parent.Occupancy.AcousticEstimator)

type RoomParams = AcousticEstimator.RoomParams


type AudioConfig = AudioPresets.AudioConfig

export type ResonixAudioClient = typeof(setmetatable({} :: {
	_net                     : any,
	_resonix                 : any,
	_pool                    : any,
	_configs                 : { [string]: AudioConfig },
	_categories              : { [string]: { string } },
	_connections             : { any },
	_listenerPositionProvider: () -> Vector3?,
	_occupancyGrid           : any?,
	_active                  : { [string]: { [any]: boolean } },
	_lastScheduled           : { [string]: number },
	_reverb                  : AudioReverb?,
	_currentRoom             : RoomParams?,
	_targetRoom              : RoomParams?,
	_lastVoxelX              : number,
	_lastVoxelY              : number,
	_lastVoxelZ              : number,
	_lastRoomUpdate          : number,
	_lerpStartClock          : number,
	_destroyed               : boolean,
}, {} :: { __index: any }))


local ResonixAudioClient   = {}
ResonixAudioClient.__index = ResonixAudioClient
ResonixAudioClient.__type  = "ResonixAudioClient"


local DEFAULT_FREQUENCY = "mid"

local ROOM_UPDATE_THROTTLE = 0.25
local ROOM_LERP_DURATION   = 0.4  
local MIXER_LEAD = 0.05
local MIXER_MIN_SPACING = 1 / 240
local PLAYBACK_GRACE = 2
local PLAYBACK_POLL = 0.25
local LOAD_TIMEOUT = 10


local OUTDOOR_ROOM: RoomParams = {
	DecayTime  = 0,
	Density    = 0,
	Diffusion  = 0,
	WetLevel   = 0,
	IsEnclosed = false,
}


local function LerpRoom(a: RoomParams, b: RoomParams, t: number): RoomParams
	return {
		DecayTime  = a.DecayTime  + (b.DecayTime  - a.DecayTime)  * t,
		Density    = a.Density    + (b.Density    - a.Density)    * t,
		Diffusion  = a.Diffusion  + (b.Diffusion  - a.Diffusion)  * t,
		WetLevel   = a.WetLevel   + (b.WetLevel   - a.WetLevel)   * t,
		IsEnclosed = t >= 1 and b.IsEnclosed or (a.IsEnclosed or b.IsEnclosed),
	}
end


local function DefaultListenerPosition(): Vector3?
	local character = Players.LocalPlayer and Players.LocalPlayer.Character
	if character then
		local root = character:FindFirstChild("HumanoidRootPart") :: BasePart?
		if root then
			return root.Position
		end
	end

	local camera = workspace.CurrentCamera
	if camera then
		return camera.CFrame.Position
	end

	return nil
end


local function ApplyTransmission(
	entry           : any,
	config          : AudioConfig,
	effectiveVolume : number,
	materialTable   : { low: number, mid: number, high: number }?
)
	local baseVolume = config.Volume or 1
	entry.Player.Volume = baseVolume * effectiveVolume

	if materialTable then
		entry.Eq.LowGain  = -(materialTable.low  * 80)
		entry.Eq.MidGain  = -(materialTable.mid  * 80)
		entry.Eq.HighGain = -(materialTable.high * 80)
	else
		entry.Eq.LowGain  = 0
		entry.Eq.MidGain  = 0
		entry.Eq.HighGain = 0
	end
end

local WET_LEVEL_DB_MAX = 0
local WET_LEVEL_DB_MIN = -36
local RESONIX_FORCE_WET = false

local function ApplyRoomToReverb(reverb: any, room: RoomParams)
	if RESONIX_FORCE_WET then
		reverb.DecayTime = 4
		reverb.Density   = 1
		reverb.Diffusion = 1
		reverb.WetLevel  = WET_LEVEL_DB_MAX
		return
	end

	reverb.DecayTime = math.max(room.DecayTime, 0.01)
	reverb.Density   = room.Density
	reverb.Diffusion = room.Diffusion
	reverb.WetLevel  = WET_LEVEL_DB_MIN + (WET_LEVEL_DB_MAX - WET_LEVEL_DB_MIN) * room.WetLevel
end

local function NextMixerTime(self: any, presetKey: string): number?
	local ok, now = pcall(function()
		return SoundService:GetMixerTime()
	end)
	if not ok or typeof(now) ~= "number" then
		return nil
	end

	local scheduled = now + MIXER_LEAD

	local last = self._lastScheduled[presetKey]
	if last and scheduled <= last + MIXER_MIN_SPACING then
		scheduled = last + MIXER_MIN_SPACING
	end
	self._lastScheduled[presetKey] = scheduled

	return scheduled
end

local function PlayAt(
	self      : any,
	presetKey : string,
	config    : AudioConfig,
	position  : Vector3,
	source    : Instance?
)
	local entry = self._pool:Acquire(presetKey, config, position)

	if entry.Reverb and self._targetRoom then
		ApplyRoomToReverb(entry.Reverb, self._targetRoom :: RoomParams)
	end

	local resonix = self._resonix
	if resonix then
		local listenerPos = self._listenerPositionProvider()
		if listenerPos then
			local preset = resonix:GetPresets()[presetKey]
			if preset then
				local excludeList = {}
				if source then
					excludeList[1] = source
				end

				local _, transmission, materialTable = resonix.SoundPropagator.ComputeTransmission(
					position,
					listenerPos,
					preset.Frequency or DEFAULT_FREQUENCY,
					preset.Radius,
					preset.Intensity,
					excludeList,
					nil,
					preset.Diffraction
				)

				ApplyTransmission(entry, config, transmission, materialTable)
			end
		end
	end

	local scheduledTime = NextMixerTime(self, presetKey)
	if scheduledTime then
		entry.Player:Play(scheduledTime)
	else
		entry.Player:Play()
	end

	local active = self._active[presetKey]
	if not active then
		active = {}
		self._active[presetKey] = active
	end
	active[entry] = true

	local released = false
	local endedConn: RBXScriptConnection? = nil

	local function release()
		if released then return end
		released = true
		if endedConn then
			endedConn:Disconnect()
			endedConn = nil
		end
		local stillActive = self._active[presetKey]
		if stillActive then
			stillActive[entry] = nil
		end
		self._pool:Release(presetKey, entry)
	end

	endedConn = entry.Player.Ended:Connect(release)
	entry.Release = release

	task.spawn(function()
		local startedPlaying = false
		local waited = 0

		-- A scheduled voice is not IsPlaying until its timestamp arrives, so the
		-- load window has to start from then rather than from Play(). Without
		-- this the lead time counts against LOAD_TIMEOUT, and a voice queued far
		-- enough ahead would be reclaimed before the mixer ever reached it.
		local graceUntil = scheduledTime and (scheduledTime + MIXER_LEAD) or nil

		while not released do
			task.wait(PLAYBACK_POLL)
			if released then return end

			if graceUntil then
				local ok, now = pcall(function()
					return SoundService:GetMixerTime()
				end)
				if ok and typeof(now) == "number" and now < graceUntil then
					continue
				end
				graceUntil = nil
			end

			waited += PLAYBACK_POLL

			local player = entry.Player
			if player.IsPlaying then
				startedPlaying = true
			elseif startedPlaying then
				task.wait(PLAYBACK_GRACE)
				if not released and not player.IsPlaying then
					release()
				end
				return
			elseif waited >= LOAD_TIMEOUT then
				release()
				return
			end
		end
	end)
end


local function GetOrCreateDeviceOutput(): AudioDeviceOutput
	local existing = SoundService:FindFirstChildOfClass("AudioDeviceOutput")
	if existing then
		return existing :: AudioDeviceOutput
	end

	local output = Instance.new("AudioDeviceOutput")
	output.Name   = "ResonixAudioOutput"
	output.Parent = SoundService
	return output
end


local function InitRoomState(self: any)
	self._currentRoom = OUTDOOR_ROOM
	self._targetRoom  = OUTDOOR_ROOM
	self._lastVoxelX  = math.huge
	self._lastVoxelY  = math.huge
	self._lastVoxelZ  = math.huge
	self._lastRoomUpdate = 0
	self._lerpStartClock  = 0
end

local function UpdateRoom(self: any)
	local grid = self._occupancyGrid
	if not grid then return end

	local now = os.clock()
	if now - self._lastRoomUpdate < ROOM_UPDATE_THROTTLE then
		return
	end
	self._lastRoomUpdate = now

	local listenerPos = self._listenerPositionProvider()
	if not listenerPos then return end

	local lvx, lvy, lvz = grid:WorldToVoxel(listenerPos)

	if lvx ~= self._lastVoxelX or lvy ~= self._lastVoxelY or lvz ~= self._lastVoxelZ then
		self._lastVoxelX = lvx
		self._lastVoxelY = lvy
		self._lastVoxelZ = lvz

		local resonix = self._resonix
		local newRoom: RoomParams
		if resonix then
			newRoom = AcousticEstimator.EstimateRoom(grid, lvx, lvy, lvz, resonix.SoundPropagator)
		else
			newRoom = OUTDOOR_ROOM
		end

		-- Start the new blend from what is actually being heard right now, not
		-- from the previous target. Voxel crossings come faster than a full
		-- lerp while walking, so using the target would snap to a room the
		-- listener never reached.
		local elapsed = ROOM_LERP_DURATION > 0
			and math.clamp((now - self._lerpStartClock) / ROOM_LERP_DURATION, 0, 1)
			or 1
		self._currentRoom = LerpRoom(self._currentRoom :: RoomParams, self._targetRoom :: RoomParams, elapsed)
		self._targetRoom  = newRoom
		self._lerpStartClock = now

	end

	local t = ROOM_LERP_DURATION > 0
		and math.clamp((now - self._lerpStartClock) / ROOM_LERP_DURATION, 0, 1)
		or 1

	local blended = LerpRoom(self._currentRoom :: RoomParams, self._targetRoom :: RoomParams, t)
	self._pool:ApplyRoom(function(reverb: AudioReverb)
		ApplyRoomToReverb(reverb, blended)
	end)
end


function ResonixAudioClient.new(
	resonixNetClient : any,
	resonix          : any?,
	occupancyGrid    : any?,
	customPresets    : { [string]: AudioConfig }?
): ResonixAudioClient

	local configs: { [string]: AudioConfig } = {}
	for key, cfg in AudioPresets do
		configs[key] = cfg
	end
	if customPresets then
		for key, cfg in customPresets do
			configs[key] = cfg
		end
	end

	local reverbMarker = occupancyGrid and GetOrCreateDeviceOutput() or nil

	local self = setmetatable({
		_net                      = resonixNetClient,
		_resonix                  = resonix,
		_pool                     = SoundPool.new(reverbMarker),
		_configs                  = configs,
		_categories               = {} :: { [string]: { string } },
		_connections              = {} :: { any },
		_listenerPositionProvider = DefaultListenerPosition,
		_occupancyGrid            = occupancyGrid,
		_active                   = {} :: { [string]: { [any]: boolean } },
		_lastScheduled            = {} :: { [string]: number },
		_destroyed                = false,
	}, ResonixAudioClient) :: any

	if occupancyGrid then
		InitRoomState(self)

		local heartbeatConn = RunService.Heartbeat:Connect(function()
			UpdateRoom(self)
		end)
		table.insert(self._connections, heartbeatConn)
	end

	local emitConn = resonixNetClient.OnEmitReceived:Connect(function(
		_serverEmissionId : number,
		presetKey         : string,
		position          : Vector3
	)
		local config = self._configs[presetKey]
		if not config then
			return
		end

		PlayAt(self, presetKey, config, position, nil)
	end)

	table.insert(self._connections, emitConn)

	return self
end


function ResonixAudioClient.SetListenerPositionProvider(
	self : ResonixAudioClient,
	Fn   : (() -> Vector3?)?
)
	assert(not self._destroyed, "ResonixAudioClient.SetListenerPositionProvider: handle is destroyed");
	(self :: any)._listenerPositionProvider = Fn or DefaultListenerPosition
end


function ResonixAudioClient.RegisterAudio(
	self      : ResonixAudioClient,
	presetKey : string,
	config    : AudioConfig
)
	assert(not self._destroyed, "ResonixAudioClient.RegisterAudio: handle is destroyed")
	assert(presetKey and presetKey ~= "", "RegisterAudio: presetKey is required")

	local hasSoundId     = config.SoundId and config.SoundId ~= ""
	local hasAudioPlayer = typeof(config.AudioPlayer) == "Instance" and config.AudioPlayer:IsA("AudioPlayer")
	local hasTemplate    = typeof(config.Template) == "Instance"
	if not (hasSoundId or hasAudioPlayer or hasTemplate) then
		warn("RegisterAudio: '" .. presetKey .. "' has none of SoundId (string), AudioPlayer (AudioPlayer instance), or Template (Instance), skipping")
		return
	end

	self._configs[presetKey] = config
end

function ResonixAudioClient.PlayLocal(
	self      : ResonixAudioClient,
	presetKey : string,
	position  : Vector3
)
	assert(not self._destroyed, "ResonixAudioClient.PlayLocal: handle is destroyed")
	local config = self._configs[presetKey]

	if not config then return end
	PlayAt(self, presetKey, config, position, nil)
end

-- Cuts every currently-playing instance of a preset. Stopping an AudioPlayer
-- does not raise Ended, so the pooled entries are released here directly.
function ResonixAudioClient.StopLocal(
	self      : ResonixAudioClient,
	presetKey : string
)
	assert(not self._destroyed, "ResonixAudioClient.StopLocal: handle is destroyed")

	local active = self._active[presetKey]
	if not active then return end

	for entry in active do
		entry.Player:Stop()
		if entry.Release then
			entry.Release()
		else
			self._pool:Release(presetKey, entry)
		end
	end
	self._active[presetKey] = nil

	self._lastScheduled[presetKey] = nil
end

function ResonixAudioClient.RegisterCategory(
	self        : ResonixAudioClient,
	categoryKey : string,
	presetKeys  : { string }
)
	assert(not self._destroyed, "ResonixAudioClient.RegisterCategory: handle is destroyed")
	self._categories[categoryKey] = presetKeys
end

function ResonixAudioClient.PlayLocalCategory(
	self        : ResonixAudioClient,
	categoryKey : string,
	position    : Vector3
)
	assert(not self._destroyed, "ResonixAudioClient.PlayLocalCategory: handle is destroyed")
	local presetKeys = self._categories[categoryKey]
	if not presetKeys or #presetKeys == 0 then return end
	local key    = presetKeys[math.random(#presetKeys)]
	local config = self._configs[key]
	if not config then return end
	PlayAt(self, key, config, position, nil)
end

function ResonixAudioClient.Destroy(self: ResonixAudioClient)
	if self._destroyed then return end
	self._destroyed = true

	for _, conn in self._connections do
		if typeof(conn) == "RBXScriptConnection" then
			conn:Disconnect()
		elseif typeof(conn) == "table" and conn.Disconnect then
			conn:Disconnect()
		end
	end
	table.clear(self._connections)

	self._pool:Destroy()
end

return ResonixAudioClient

Video:

The micro profiler seems stable after rapid firing?

Thanks for sharing that code – I will try to reproduce it on my side.

In the after-reverb case, can you click Dump > 512 frames, and share the file that gets generated? It should get put into your logs folder; it’s an html file

microprofile-20260901-181610.txt (4.8 MB)
Here, I changed the format to txt tho so I can send it, it’s not allowing me to send .html.

Hey, any news for the fix? I just hope we can fix this one