SoundProvider - Lightweight One-Shot Sound Playback Module

This is a small lightweight system that I use in my game for playing randomized one-off sounds in UI and in 3D space. It allows you to choose from a series of sounds without accidentally repeating the same sound.

You can use this system to:

  • Play single-shot sounds in
    • 3D space
    • GUI
  • Replicate a sound in 3D space to all clients (or all clients but one).

I’m posting it here since I open-sourced it in my Discord server today. It might be a quick drop-in for your game. Audio design is a tremendous source of juice/satisfaction in video games, and is a piece of polish often neglected.

Code and Examples:
SoundProvider.rbxl (52,9 KB)

The sounds configured in the above example are for the purpose of demonstrating how to use the module. You can add or remove them and organize them within the sounds table as you see fit.



image

Usage

Sound Selection

SoundData is defined within SoundProvider.Sounds. This data contains a number for the previous sound played (so we can avoid playing duplicates, and accidentally re-playing sounds that are currently playing), and a list of sound instances to choose from.

You must pass SoundData to GetNext and GetOneOf to obtain a sound to play with Play, PlayAt, or PlayForOthers.

The Sound instances specified in SoundData support an attribute called TimePositionOffset, which allows you to specify an alternative starting time for audio that has been trimmed improperly, such as some audio already uploaded on Roblox.

type SoundData = {
	current:number, -- The previously played sound from the list of sounds. 
	sounds:{Sound} -- A list of sound instances to pick from for this type of sound.
}

An example SoundData entry:

	hit = {
		current = 0,
		sounds = {
			script:WaitForChild("Hit1"),
			script:WaitForChild("Hit2"),
			script:WaitForChild("Hit3"),
			script:WaitForChild("Hit4"),
			script:WaitForChild("Hit5"),
			script:WaitForChild("Hit6"),
		}
	},

GetNext(SoundData, number)

Choose the next sound from the sound data.

GetOneOf(SoundData)

Choose a random sound from the sound data that was not the previously played sound from this data.


Sound Playback

Play(Sound)

Play a sound (such as for UI).

PlayAt(Sound, Vector3)

Play a sound at a location.

PlayForOthers(playerToExclude:Player, Sound, Vector3)

Play a sound for everyone in the server at a location, except for the specified player. (e.g. you played the sound for them already locally, and the server needs to play it for everyone else).

Note that in most cases, you want to detect when you want to play a sound locally on all clients and play it there instead, rather than using this approach. Detecting when to play the sound locally instead can reduce latency for clients and reduce network usage.

PlayForOthers is intended for niche situations where design does not easily allow this, or when there are no concrete client-side events for clients to use to know when to play the sound.


Known Issues

Playing a sound from SoundData with a very short list of sounds too frequently can result in the previous play of that sound being cut off. To avoid this, you should avoid rapidly playing sounds, or provide sufficient sound variants (or the same sound multiple times) in the SoundData sounds array.


Code

SoundProvider ModuleScript

--!strict

local RunService = game:GetService("RunService")

local sounds = require(script:WaitForChild("Sounds"))
local soundRemote:RemoteEvent = script:WaitForChild("ReplicateSound")
local random:Random = Random.new()

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

local module = {}
module.sounds = sounds
module.replicateSoundEvent = soundRemote

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

-- Choose the next sound from the sound data.
function module.GetNext(soundInstanceData:sounds.SoundData, currentIndex:number)
	local sounds:{Sound} = soundInstanceData.sounds
	soundInstanceData.current = (soundInstanceData.current+1) % #sounds
	return sounds[soundInstanceData.current+1]
end


-- Choose a random sound from the sound data that was not the previously played sound from this data.
function module.GetOneOf(soundInstanceData:sounds.SoundData)
	local sounds:{Sound} = soundInstanceData.sounds
	soundInstanceData.current = (soundInstanceData.current + random:NextInteger(1, #sounds-1)) % #sounds
	return sounds[soundInstanceData.current+1]
end


-- Play a sound (such as for UI).
function module.Play(sound:Sound)
	local offset:number = sound:GetAttribute("TimePositionOffset")
	sound.TimePosition = if offset ~= nil then offset else 0
	sound:Play()
end


-- Play a sound at a location.
function module.PlayAt(sound:Sound, position:Vector3)
	local sound:Sound = sound:Clone()
	local att:Attachment = Instance.new("Attachment")
	sound.Parent = att
	att.WorldPosition = position
	att.Parent = workspace.Terrain
	
	local offset:number = sound:GetAttribute("TimePositionOffset")
	sound.TimePosition = if offset ~= nil then offset else 0
	sound.Ended:Connect(function()
		sound:Destroy()
		att:Destroy()
	end)
	
	sound:Play()
end


-- Play a sound for everyone in the server at a location, except for the specified player 
-- (e.g. you played the sound for them already locally, and the server needs to play it for everyone else).
---- Note that in most cases, you want to detect when you want to play a sound locally on all clients and play it there instead, rather than using 
---- this approach. Detecting when to play the sound locally instead can reduce latency for other clients and reduce network activity.
---- PlayForOthers is intended for niche situations where design does not easily allow this, or when there are no concrete client-side events for 
---- clients to use to know when to play the sound.
if RunService:IsServer() then
	function module.PlayForOthers(playerToExclude:Player, sound:Sound, position:Vector3)
		soundRemote:FireAllClients(playerToExclude, sound, position)
	end
end

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

return module

SoundProvider.Sound ModuleScript

--!strict

export type SoundData = {
	current:number, -- The previously played sound from the list of sounds. 
	sounds:{Sound} -- A list of sound instances to pick from for this type of sound.
}

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

local sounds = {
	hit = {
		current = 0,
		sounds = {
			script:WaitForChild("Hit1"),
			script:WaitForChild("Hit2"),
			script:WaitForChild("Hit3"),
			script:WaitForChild("Hit4"),
			script:WaitForChild("Hit5"),
			script:WaitForChild("Hit6"),
		}
	},
	attack = {
		current = 0,
		sounds = {
			script:WaitForChild("AttackSwoosh1"),
			script:WaitForChild("AttackSwoosh2"),
			script:WaitForChild("AttackSwoosh3"),
		}
	},
	buttonHover = {
		current = 0,
		sounds = {
			script:WaitForChild("ButtonHover1"),
			script:WaitForChild("ButtonHover2"),
			script:WaitForChild("ButtonHover3"),
		}
	},
	buttonPress = {
		current = 0,
		sounds = {
			script:WaitForChild("ButtonPress1"),
			script:WaitForChild("ButtonPress2"),
			script:WaitForChild("ButtonPress3"),
		}
	},
	buttonPressSpecial = {
		current = 0,
		sounds = {
			script:WaitForChild("ButtonPressSpecial1"),
			script:WaitForChild("ButtonPressSpecial2"),
			script:WaitForChild("ButtonPressSpecial3"),
		}
	},
}

return sounds

Examples

PlayGUILocal LocalScript

local SoundProvider = require(game:GetService("ReplicatedStorage"):WaitForChild("SoundProvider"))

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

-- Set up hover sounds
for _,button in pairs(script.Parent:GetChildren()) do
	if button:IsA("GuiButton") then
		button.MouseEnter:Connect(function()
			SoundProvider.Play(SoundProvider.GetNext(SoundProvider.sounds.buttonHover))
		end)
	end
end

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

-- Set up specific sounds
script.Parent:WaitForChild("Hit").Activated:Connect(function()
	SoundProvider.Play(SoundProvider.GetOneOf(SoundProvider.sounds.hit))
end)


script.Parent:WaitForChild("Attack").Activated:Connect(function()
	SoundProvider.Play(SoundProvider.GetOneOf(SoundProvider.sounds.attack))
end)


script.Parent:WaitForChild("Button").Activated:Connect(function()
	SoundProvider.Play(SoundProvider.GetNext(SoundProvider.sounds.buttonPress))
end)


script.Parent:WaitForChild("SpecialButton").Activated:Connect(function()
	SoundProvider.Play(SoundProvider.GetNext(SoundProvider.sounds.buttonPressSpecial))
end)

PlayContinuousServer Script

local Players = game:GetService("Players")
local SoundProvider = require(game:GetService("ReplicatedStorage"):WaitForChild("SoundProvider"))

local color:Color3 = script.Parent.Color

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

while true do
	task.wait(0.5)
	SoundProvider.PlayForOthers(nil, SoundProvider.GetNext(SoundProvider.sounds.buttonPress), script.Parent.Position) -- script.Parent also works.
	
	script.Parent.Color = Color3.new(1,0,0)
	task.wait(0.5)
	script.Parent.Color = color
end

PlayPositionalServer Script

local Players = game:GetService("Players")
local SoundProvider = require(game:GetService("ReplicatedStorage"):WaitForChild("SoundProvider"))

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

local debounce:boolean = false
script.Parent.Touched:Connect(function(hit)
	if debounce then return end
	debounce = true
	
	-- We exclude the player who touched the button because they are playing the sound locally immediately when they touch it (see: PlayAtLocal).
	-- If we only played the sound on the server, there would be a delay between the player pressing the button, and them hearing the sound.
	local character:Model = hit:FindFirstAncestorOfClass("Model")
	local player:Player? = Players:GetPlayerFromCharacter(character)
	
	if player ~= nil then
		SoundProvider.PlayForOthers(player, SoundProvider.GetNext(SoundProvider.sounds.buttonPress), script.Parent.Position)
	end
	
	wait(1)
	debounce = false
end)

PlayPositionalLocal LocalScript

local Players = game:GetService("Players")
local SoundProvider = require(game:GetService("ReplicatedStorage"):WaitForChild("SoundProvider"))

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

local debounce:boolean = false
script.Parent.Touched:Connect(function(hit)
	if debounce then return end
	debounce = true 
	
	-- Play the sound locally, no other player except for this client can hear this sound.
	-- We use PlayPositionalServer instead to send the sound to everyone else.
	SoundProvider.PlayAt(SoundProvider.GetNext(SoundProvider.sounds.buttonPress), script.Parent.Position)
	
	wait(1)
	debounce = false
end)


Further Reading

You can find free sound effects already uploaded on Roblox: https://create.roblox.com/marketplace/soundeffects
Or on various CC0 free sound websites like MixKit, FreeSound, etc.

9 Likes

I think it’s because you didn’t use SoundService:PlayLocalSound.

Seems like a good resource, but what is SoundProvider.sounds? Is it a folder containing the audio objects, or a module?

In my use case, I need to play a sound in 2D for the player who’s playing it and I need to play the same sound in 3D for other players.

Just looking at the “SoundProvider.Sound ModuleScript” why not make tables for the different types of sounds (Hit, Attack, ButtonHover, ButtonPress)…I feel you could save a lot of space

Forgot about PlayLocalSound, I will determine if it’s fine for a 1:1 replacement on any of the functions when I get time.

SoundProvider.Sounds is a module script within SoundProvider, it contains various Sound instances as children. Please see the hierarchy image, code, and examples.

You can accomplish your goal by using Play locally, and firing an event to the server telling it to play the sound for all others. I can probably wrap up this boilerplate by making the remote event bidirectional and adding a listener for it.

1 Like

These are simply examples. You can remove them or add your own. An entry in this table is one sound with several variants. You can organize them however you like.