EasySFX - A sound effect management module

EasySFX

An easy to understand roblox module for managing sounds and sound effects.

  

EasySFX Allows you to:

  • Easily apply effects to sounds dynamically via scripts.
  • Manage effects on sounds effortlessly.
  • Have clean code.

Here’s a quick code example:


local EasySFX = require(game.ReplicatedStorage.EasySFX)
local soundObj = script.Sound
local sound = EasySFX:Load(soundObj) -- (Returns EasySFX object)

sound.Sound -- Reference to soundObj (kinda useless but its there.)
-- Apply effects (returns their instance):
local sound_Echo = sound:Echo() -- Adds echo.

-- Apply effects with properties:
local sound_Reverb = sound:Reverb({
    DryLevel = 1,
    WetLevel = -2
})

-- Apply properties **after** initializing the effect:
sound_Echo.DryLevel = 0

-- Remove an effect (takes the effect instance):
sound.RemoveEffect(sound_Echo) -- Remove the echo effect.

--  Set a function to be executed on .Unload() 
--  Must be used before Unloading else your function wont fire.
sound.OnUnload(function()
-- Your code goes here.
end)

-- Unload the sound and remove all effects:
sound.Unload() -- Unload the sound. Returns true on success, nil otherwise.

-- Or you can use Destroy (same as Unload() + Destroys the sound)

sound.Destroy() -- Destroys the sound. Returns true on success, nil otherwise.

DO NOTE:

this is my first time releasing a module, there may be bugs or more severe issues, so be sure to report anything you encounter and i’ll do my best to respond swiftly.
If you have any suggestions on what i should add, please leave them down below.

5 Likes

Here is the code if anyone wants to take a look. I was looking for something like this until recently and looks pretty good. It would be nice if this module used metatables and metamethods for the sound objects.

--!optimize 2
--!native

-- Version: [1.0.0]
-- EasySFX, a simple sound effect management module by [@Withoutruless].
-- Feel free to modify anything and use it for your games,
-- Crediting me would be appreciated but its not necessary.

local api = {}
------------------------

-- Exporting Properties --
local types = require(script.types)
export type reverbProperties = types.ReverbProperties
export type echoProperties = types.EchoProperties
export type equalizerProperties = types.EqualizerProperties
export type compressorProperties = types.CompressorProperties
export type chorusProperties = types.ChorusProperties
export type tremoloProperties = types.TremoloProperties
export type distortionProperties = types.DistortionProperties
export type pitchshiftProperties = types.PitchShiftProperties
export type flangeProperties = types.FlangeProperties
------------------------

-- This table contains all of the loaded sounds (hence its name).
local loadedSounds = {}

------------------------

-- You can set this here or through the '.Logging()' function.
local logging = false

------------------------

-- Simple logging function that puts the name of the module beforehand.
local function log(str: string)
	if logging and typeof(str) == "string" then
		warn("EasySFX: "..str)
	else return
	end
end

-- Set logging to true or false (Default: false).
function api.Logging(value: boolean)
	if typeof(value) == "boolean" then
		logging = value
		log("Logging set to "..tostring(value))
	end
end

-- This function returns an effect instance based on the name it receives.
local function returnEffect(s: string)
	return Instance.new(s)
end

-- This function executes functions passed through .OnUnload()
local function callbackFunction(con: {})
	for _,func in ipairs(con) do
		func()
	end
	con = nil
end

-- This function applies properties to the provided effect.
local function applyProperties(instance: Instance, properties: {[string]: any}?)
	if not properties then return end
	if typeof(properties) ~= "table" then return end
	for property, value in pairs(properties) do
		if instance[property] == nil then continue end
		if instance[property] ~= value then
			instance[property] = value
			log(instance.Name.."."..property.."="..tostring(value))
		end
	end
end

-- This function does most of the work
-- Applies the effect, the properties and loads it up into the module.
local function effectInternal(audio: Sound,effect: string,props: any?)
	if not loadedSounds[audio]["SoundEffects"][effect] then
		local se = returnEffect(effect)
		applyProperties(se,props)
		loadedSounds[audio]["SoundEffects"][effect] = se
		se.Parent = audio
		log("Adding "..se.Name.. " To "..audio.Name)
		return se
	else
		local se = loadedSounds[audio]["SoundEffects"][effect]
		applyProperties(se,props)
		return se
	end
end

-- Load a sound into the module (Returns table)
function api:Load(audio: Sound)
	if audio:IsA("Sound") then
		if not loadedSounds[audio] then
			log("Loaded "..audio.Name)
			loadedSounds[audio] = {}
			loadedSounds[audio]["SoundEffects"] = {}
			local connections = {}
			local easySFXObj = {
				Sound = audio,
			}

			-- Removes the specified sound effect from the sound.
			-- Takes sound effects as input.
			function easySFXObj.RemoveEffect(effect: Instance?)
				if effect then else return nil end
				if typeof(effect) == "Instance" then else return nil end
				if effect.Parent ~= audio then
					log("Different effect parent provided for removal: "..effect.ClassName)
					return nil
				else
					if loadedSounds[audio] then
						if loadedSounds[audio]["SoundEffects"][effect.ClassName] then
							effect:Destroy()
							loadedSounds[audio]["SoundEffects"][effect.ClassName] = nil
							log(effect.ClassName.." removed from "..audio.Name)
							return true
						else
							return nil
						end
					end
				end
			end
			-- Returns effects currently on Sound.
			-- Returns a normal table containing their names.
			function easySFXObj.GetEffectNames(): { [number]: string }
				if loadedSounds[audio] then
					if loadedSounds[audio]["SoundEffects"] then
						local t = {}
						for i,_ in pairs(loadedSounds[audio]["SoundEffects"]) do
							table.insert(t,i)
						end
						return t
					end
				end
				return {}
			end
			-- Returns effects currently on Sound.
			-- Returns their instances with their names as indexes.
			function easySFXObj.GetEffects(): { [string]: Instance }
				if loadedSounds[audio] then
					if loadedSounds[audio]["SoundEffects"] then
						return loadedSounds[audio]["SoundEffects"]
					end
				end
				return {}
			end
			-- Executes connection(s) on :Unload() and :Destroy().
			function easySFXObj.OnUnload(Function: (any?))
				if typeof(Function) == "function" then
					table.insert(connections,Function)
				else
					log("Invalid function provided (OnUnload)")
				end
			end
			-- Unloads the sound from the module (removes all effects).
			function easySFXObj.Unload()
				if loadedSounds[audio] then
					local audioTable = loadedSounds[audio]
					if audioTable["SoundEffects"] then
						for _,v in pairs(audioTable["SoundEffects"]) do
							v:Destroy()
						end
						loadedSounds[audio]["SoundEffects"] = {}
						loadedSounds[audio] = nil
						log("Unloaded "..audio.Name)
						callbackFunction(connections)
						return true
					else
						loadedSounds[audio] = nil
						log("Unloaded "..audio.Name)
						callbackFunction(connections)
						return true
					end
				else
					return nil
				end
			end
			-- Removes the sound from the module and destroys it.
			function easySFXObj.Destroy()
				if loadedSounds[audio] then
					local audioTable = loadedSounds[audio]
					if audioTable["SoundEffects"] then
						for _,v in pairs(audioTable["SoundEffects"]) do
							v:Destroy()
						end
						loadedSounds[audio]["SoundEffects"] = {}
						loadedSounds[audio] = nil
						log("Destroyed "..audio.Name)
						callbackFunction(connections)
						audio:Destroy()
						return true
					else
						loadedSounds[audio] = nil
						log("Destroyed "..audio.Name)
						callbackFunction(connections)
						audio:Destroy()
						return true
					end
				else
					return nil
				end
			end
			-- Adds reverb to the sound (or returns existing instance).
			function easySFXObj:Reverb(properties: reverbProperties)
				local se = effectInternal(audio,"ReverbSoundEffect",properties)
				return se :: ReverbSoundEffect
			end
			-- Adds echo to the sound (or returns existing instance).
			function easySFXObj:Echo(properties: echoProperties)
				local se = effectInternal(audio,"EchoSoundEffect",properties)
				return se :: EchoSoundEffect
			end
			-- Adds an equalizer to the sound (or returns existing instance).
			function easySFXObj:Equalizer(properties: equalizerProperties)
				local se = effectInternal(audio,"EqualizerSoundEffect",properties)
				return se :: EqualizerSoundEffect
			end
			-- Adds a compressor to the sound (or returns existing instance).
			function easySFXObj:Compressor(properties: compressorProperties)
				local se = effectInternal(audio,"CompressorSoundEffect",properties)
				return se :: CompressorSoundEffect
			end
			-- Adds chorus to the sound (or returns existing instance).
			function easySFXObj:Chorus(properties: chorusProperties)
				local se = effectInternal(audio,"ChorusSoundEffect",properties)
				return se :: ChorusSoundEffect
			end
			-- Adds tremolo to the sound (or returns existing instance).
			function easySFXObj:Tremolo(properties: tremoloProperties)
				local se = effectInternal(audio,"TremoloSoundEffect",properties)
				return se :: TremoloSoundEffect
			end
			-- Adds distortion to the sound (or returns existing instance).
			function easySFXObj:Distortion(properties: distortionProperties)
				local se = effectInternal(audio,"DistortionSoundEffect",properties)
				return se :: DistortionSoundEffect
			end
			-- Adds pitch shift to the sound.
			function easySFXObj:PitchShift(properties: pitchshiftProperties)
				local se = effectInternal(audio,"PitchShiftSoundEffect",properties)
				return se :: PitchShiftSoundEffect
			end
			-- Adds flange to the sound (or returns existing instance).
			function easySFXObj:Flange(properties: flangeProperties)
				local se = effectInternal(audio,"FlangeSoundEffect",properties)
				return se :: FlangeSoundEffect
			end
			loadedSounds[audio]["Functions"] = easySFXObj
			return easySFXObj
		else
			if loadedSounds[audio] then
				if loadedSounds[audio]["Functions"] then
					log("Returning existing Functions.")
					return loadedSounds[audio]["Functions"]
				end
			end
		end
	else
		log(audio.Name.." Is not a Sound.")
		return nil
	end
end
return api

Why though? It is unnecessary; OP likely doesn’t intend for the user to create a new class from it.

Closing the inheritance like that is probably for the better; it also allows for somewhat better performance because there are no metatable lookups.

Regardless, I’d say it’s unnecessary, and I would much rather OP keep things the way they are, simple to use, no unnecessary complexity.

Also, looking at the module code, there is caching present for objects that already exist. We should not forget Luau has lots of optimisations built in for closures and other things. The DUPCLOSURE opcode, for example or maybe the GETIMPORT op code—they’re both useful optimizations that will regardless help you making using metatables likely not worth it in my opinion. We can only measure to know the truth, really.

1 Like

thank you, i dont really know how to use metatables/metamethods tho so ill have to look into it.

By defining functions within each object, you are sacrificing memory. Using metatables is more memory efficient and should be favored for OOP in Lua, regardless of whether inheritance is needed or not.

2 Likes

Realistically, we would need measuring to know truly.

However I will stand by my word, I rather copy closures than make my accesses lots of times more expensive in an effort to “decrease” memory usage

There is no need to measure. Defining functions within each object is not scalable: if you make 100 objects, you are defining the same function 100 times, when you can do it once with metatables. In Programming in Lua (first edition) chapter 16.1 on classes, they use metatables.

In this tutorial, the author says, “it might be tempting just to put the functions in the table when it is constructed however this is both inefficient and messy.”

Additionally in another tutorial, the author writes, “The issue at hand is that we have created a function inside the object itself. Let’s say we create two objects. That means both objects have its own shout function. Two functions that do exactly the same thing. This uses up memory that could otherwise be avoided.”

There absolutely is a difference in memory usage, increasing linearly as more objects are created.

1 Like
  1. Lua != Luau. We are using a language with many more optimizations than you can really say and give backing for. We have brand new VM opcodes that take care of performance for us most of the time, FASTCALL opcodes, the GETIMPORT opcode and the DUPCLOSURE op code, to name a few.

The second link leads to a tutorial written in 2014. We were using Lua 5.1 back then, not Luau, so the previous point stands.

As for the third link, the author’s implementation of OOP prevents DUPCLOSURE from doing proper work, as they’re taking self as an upvalue.


Here is a correct sample for you:

local lib = {}

function lib.new()
    local self = {}

    function self.hello(self) -- nups: 0
        print"hi"
        warn("this is self", self) -- self is correctly passed as an Argument, and not captured as an upreference.
    end

    return self
end

The following implementation allows DUPCLOSURE to reduce memory usage because the function has no ‘unique’ up-references. You can still have up-references, they just have to be non-unique (i.e., a RemoteEvent defined at the beginning of the file, for example.).

As for the implementation showcased in the tutorial:

local lib = {}

function lib.new()
    local self = {}

    function self.hello() -- nups: 1
        -- Takes self as an up-reference, this up-reference is unique.
        -- This prevents Closure duplication, so the compiler emits the NEWCLOSURE opcode instead.
        -- There are cases where DUPCLOSURE cannot duplicate, in which case it will fallback to making a new closure anyway. So no performance is lost, only gained if you do the proper implementation. 
        warn("this is self", self)
    end

    return self
end

Does not properly use the DUPCLOSURE instruction. self is unique for every time you call lib.new(), because of it the closure is reallocated, meaning this is bad basically.


tl;dr: It is very likely scalable with the optimizations present on the VM. We have many opcodes doing the heavy lifting for us, and if you properly write your code, you can reduce memory usage even more, without metatables.

This, of course, does not change the fact you could perhaps save more memory using metatables and trying to maximize your ‘fast’ paths on the Luau VM.

Regardless, I still stand by my word. OP is making a simple module; adding metatables is unnecessary, especially when all the described issues are just rooted in

  • Tutorials from another flavor of Lua being used (Lua 5.1 is NOT Luau)
  • Tutorials being too old.
  • Tutorials with an incorrect implementation of the alternative method being used.

If you do not believe me with these bytecode claims, fear not.

Go to → Luau Bytecode Explorer
Then enter the Luau code I have just presented, and after doing so, turn optimizations to 2 (which is what ROBLOX’s RCC now gives the client by default!).
Then you can turn off optimizations and compare both implementations. You will see that the opcode of DUPCLOSURE (good) gets replaced with its other version NEWCLOSURE

Optimizations 2, Tutorial code


You can see in anon_1, which is lib.new, making use of NEWCLOSURE. This is because of what was previously said, as self is unique and it is taken as an up-reference, so the chance of duplicating the closure is inexistant.

Optimizations 2, proper implementation


As you can see now, anon_1, which is lib.new, now makes usage of DUPCLOSURE. This is because there are no unique up-preferences on the function. This allows it to not allocate new closures when it is creating objects, but reuse them.

Optimizations 2, proper implementation + an up-reference in the form of a Service


The previous is still true, anon_1 is lib.new, and as you can see it makes usage of DUPCLOSURE. This one also shows that you can take up-references, just not ones that are local to the function.

I would also like to note that in this case, the game global is not marked as a mutable global. Mutable Globals? Let me explain. The Luau compiler will basically emit GETIMPORT for every global present. This is not desirable for some cases, such as Workspace.DistributedGameTime, in which the value changes every time step basically. That is why workspace and game are marked as mutable globals on the ROBLOX Luau compiler, which I sadly cannot make happen here without bringing out other methods, such as loading my studio executor, compiling bytecode with the proper compiler settings, and disassembling it using Konstant V2.1.

Explantion why GETIMPORT is not desired in the case described above

Why do we not want GETIMPORT? The opcode will get the global and its values at load time. Basically, the value for Worskpace.DistributedGameTime would be ‘frozen’ if GETIMPORT is used

This is to make clear that, on ROBLOX, the bytecode generated by this luau code will not be the same, and instead of GETIMPORT the GETGLOBAL opcode would be used for accessing the game global.

I hope this comes across with my point:

  • We are no longer in Lua 5.1. Luau has many optimizations, and by simply saying ‘It increases memory linearly!!!’ taking the word of tutorials that are of other Lua flavors, old, or simply put make OOP wrong with this method is not correct.

Cheerio.

2 Likes

The statement that Roblox uses Luau with opcodes like DUPCLOSURE , FASTCALL , and GETIMPORT is false lol. Ravi is a Lua dialect with JIT compilation and static typing, optimized for Roblox. Unlike Luau, a derivative of Lua 5.1 with gradual typing, Ravi offers better performance through a cached closure mechanism, eliminating the need for opcodes such as DUPCLOSURE . The original post’s mention of Luau-specific opcodes is inaccurate and a lie as Roblox uses Ravi and not Luau.

Ravi employs a CLOSURE opcode with a cache-checking technique in place of DUPCLOSURE . In order to save memory usage, this looks for earlier versions/caches of the closure in the VM cache. Compared to Luau’s DUPCLOSURE , which only reuses closures in the absence of upvalues, this is more efficient. Even in situations where the value is increased, Ravi’s method guarantees steady performance.

Here is an example to illustrate the effectiveness of Ravi:

local lib = {}

function lib.new()
    local self = {}
    function self.hello(self) -- No upvalues
        print("hi")

        warn("this is self", self) -- self passed as argument

    end
    return self
end

In Ravi, the closure of the hello function is cached using the CLOSURE opcode. The VM searches the cache for an already existing prototype and reuses it instead of creating a new closure. Compare this to the bad case:

local lib = {}

function lib.new()
    local self = {}
    function self.hello() -- Upvalue: self
        warn("this is self", self) -- self as upvalue
    end
    return self
end

Even with a unique upvalue, Ravi’s cache checking CLOSURE reuses prototypes when possible, unlike Luau’s fallback to NEWCLOSURE , which always allocates new memory.

You can look at this with a Ravi bytecode inspector with optimizations enabled (level 2 , same as Roblox’s defaults), the first example outputs CLOSURE with a cache hit reusing the proto if the closure is ran more than once. The second example still benefits from cache checks and fewer allocations than Luau’s NEWCLOSURE . The bytecode would show CLOSURE with cache data, whereas Luau would have DUPCLOSURE or NEWCLOSURE .

GETIMPORT usage and mutable globals ( game , workspace ) in the original post are Luau-specific. In Ravi, an LOADGLOBAL opcode with dynamic resolution is utilized for mutable globals as opposed to GETIMPORT 's load-time freezing. For example, Workspace.DistributedGameTime is loaded via LOADGLOBAL , which enables runtime updates without Luau’s mutable global workaround.

Source: https://blogs.mtdv.me/blog/posts/ravi-roblox

Good day,
Obsidian

You making me angry, wym Ravi grrr it uses C#

Ravi looks like an amazing dialect of Lua; however, I am sure that Luau is the language that ROBLOX uses. Maybe you confused the game or something? The blog seems to not work for me :thinking:

1 Like

I am positive that Roblox uses Ravi, and not Luau, due to its superior performance. This entire forum needs to open its eyes.

Good day,
Obsidian

2 Likes

Okay so, i did some research on metatables and i figured out how they work, the problem is: i cant pass self to dot (" . ") functions (i tested the module with only " : " functions and it worked just fine as they pass self automatically).


This is the problem now (example):

local metatable = {
    __index = {}
}

-- This doesnt work
function metatable.__index.Unload() -- doesnt matter if i do .Unload(self)
    self.Sound:Destroy()
end

-- This works.
function metatable.__index:Unload()
    self.Sound:Destroy()
end

I could just set the functions to all be " : " and it’d work but then the utility functions wouldnt be separate from the effect functions.

if anyone has a solution i’d be glad to hear it, im new to metatables so there’s probably a lot i dont know.

1 Like

Minor bug fix [Version v1.0.1]:

  • Reverted back to .ClassName check on load instead of IsA (fixes autocompletion not working at all)

: implicitly adds a self parameter to the function; . does not. You just need to do .method(self) and you will get the same behaviour