Recipes¶
Every recipe uses this small helper module. Put it in ReplicatedStorage.AudioUtil.
-- ReplicatedStorage/AudioUtil (ModuleScript)
local AudioUtil = {}
-- Create an instance, set properties, parent it.
function AudioUtil.new(className: string, props: { [string]: any }?, parent: Instance?): any
local inst = Instance.new(className)
for key, value in props or {} do
(inst :: any)[key] = value
end
inst.Parent = parent
return inst
end
-- Wire source -> target. targetPin defaults to "Input" ("Sidechain" for a compressor's key).
function AudioUtil.wire(source: Instance, target: Instance, targetPin: string?): Wire
local w = Instance.new("Wire")
w.SourceInstance = source
w.TargetInstance = target
if targetPin then
w.TargetName = targetPin
end
w.Parent = target
return w
end
-- Wire a list of nodes in series: a -> b -> c -> ...
function AudioUtil.chain(...: Instance)
local nodes = { ... }
for i = 1, #nodes - 1 do
AudioUtil.wire(nodes[i], nodes[i + 1])
end
end
return AudioUtil
Server or client?
Each recipe says where it runs. Server means everyone gets the same graph. Client means only that player hears the result. See Client, server and limits.
Playback¶
UI click (2D) · client¶
local SoundService = game:GetService("SoundService")
local AudioUtil = require(game.ReplicatedStorage.AudioUtil)
local click = AudioUtil.new("AudioPlayer", { Asset = "rbxassetid://0000000", Volume = 0.8 }, SoundService)
local out = AudioUtil.new("AudioDeviceOutput", nil, SoundService)
AudioUtil.wire(click, out)
button.Activated:Connect(function()
click.TimePosition = 0 -- Stop() pauses, so rewind for retriggers
click:Play()
end)
One-shot 3D sound at a point · client¶
local Debris = game:GetService("Debris")
local AudioUtil = require(game.ReplicatedStorage.AudioUtil)
local function playAt(position: Vector3, asset: string, volume: number?)
local anchor = Instance.new("Attachment")
anchor.Parent = workspace.Terrain
anchor.WorldPosition = position
local player = AudioUtil.new("AudioPlayer", { Asset = asset, Volume = volume or 1 }, anchor)
local emitter = AudioUtil.new("AudioEmitter", {
DistanceAttenuationMode = Enum.DistanceAttenuationMode.InverseTapered,
DistanceAttenuationBounds = NumberRange.new(5, 150),
}, anchor)
AudioUtil.wire(player, emitter)
player.Ended:Once(function() anchor:Destroy() end)
Debris:AddItem(anchor, 30) -- safety net if the asset never loads
player:Play()
end
This assumes the default listener (ListenerLocation = Camera or Character, or voice enabled). With None, make a listener first (see Underwater).
Looping ambience on a part · server¶
local AudioUtil = require(game.ReplicatedStorage.AudioUtil)
local part = workspace.Generator
local hum = AudioUtil.new("AudioPlayer", { Asset = "rbxassetid://0000000", Looping = true }, part)
local emitter = AudioUtil.new("AudioEmitter", {
DistanceAttenuationMode = Enum.DistanceAttenuationMode.Linear,
DistanceAttenuationBounds = NumberRange.new(8, 80),
}, part)
AudioUtil.wire(hum, emitter)
hum:Play() -- replicates; every client renders it
A sound for one player only · server¶
local SoundService = game:GetService("SoundService")
local AudioUtil = require(game.ReplicatedStorage.AudioUtil)
local function playFor(target: Player, asset: string, seconds: number)
local folder = AudioUtil.new("Folder", { Name = "Private_" .. target.UserId }, SoundService)
local sfx = AudioUtil.new("AudioPlayer", { Asset = asset }, folder)
local out = AudioUtil.new("AudioDeviceOutput", { Player = target }, folder) -- only `target` hears
AudioUtil.wire(sfx, out)
sfx:Play()
task.delay(seconds, function() folder:Destroy() end) -- the server renders nothing; don't wait on Ended here
end
Same source, spatial for others and in-ear for you · client¶
Fan one source out to two places:
AudioUtil.wire(radioBus, beltEmitter) -- others near you hear it from your belt
AudioUtil.wire(radioBus, myEarOutput) -- you hear it 2D in your ear
Beat-synced music changes · client¶
local SoundService = game:GetService("SoundService")
local BPM = 128
local BAR = 60 / BPM * 4
local startedAt = SoundService:GetMixerTime() + 0.1
calm:Play(startedAt)
-- Switch tracks exactly on the next bar line.
local function switchOnNextBar(from: AudioPlayer, to: AudioPlayer)
local now = SoundService:GetMixerTime()
local nextBar = startedAt + (math.floor((now - startedAt) / BAR) + 1) * BAR
to.TimePosition = 0
from:Stop(nextBar)
to:Play(nextBar)
end
Play(atTime) returns an action id. Keep it if you might need to Cancel(id) before it fires.
Voice¶
Proximity range and directional voices · server¶
local Players = game:GetService("Players")
local RANGE = { [0] = 1, [8] = 1, [40] = 0.35, [70] = 0 } -- studs -> volume
local FACING = { [0] = 1, [90] = 0.7, [180] = 0.4 } -- degrees off the face -> volume
local function tune(character: Model)
local emitter = character:WaitForChild("AudioEmitter", 10) :: AudioEmitter?
if not emitter then return end
emitter:SetDistanceAttenuation(RANGE)
emitter:SetAngleAttenuation(FACING)
end
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(tune) -- the voice emitter is rebuilt on every respawn
if player.Character then tune(player.Character) end
end)
For a preset instead of a curve, set DistanceAttenuationMode and DistanceAttenuationBounds. For the old voice range, set VoiceChatService.DefaultDistanceAttenuation = Legacy in Studio.
Filter one player's voice (gas mask, helmet) · server¶
Insert an effect between the mic and the voice emitter:
local Players = game:GetService("Players")
local AudioUtil = require(game.ReplicatedStorage.AudioUtil)
local function insertBetween(source: Instance, target: Instance, effect: Instance)
for _, w in source:GetConnectedWires("Output") do
if w.TargetInstance == target then
w.TargetInstance = effect
end
end
AudioUtil.wire(effect, target)
end
local function applyMask(player: Player)
local character = player.Character
local mic = player:FindFirstChildOfClass("AudioDeviceInput")
local emitter = character and character:FindFirstChild("AudioEmitter")
if not (mic and emitter) or character:FindFirstChild("MaskEQ") then return end
local eq = AudioUtil.new("AudioEqualizer", {
Name = "MaskEQ",
LowGain = -6, MidGain = 0, HighGain = -30,
MidRange = NumberRange.new(350, 2200),
}, character)
insertBetween(mic, emitter, eq)
end
Call applyMask when the mask goes on. To remove it, point the wire back at the emitter and destroy the EQ. On respawn, the default setup rebuilds a plain wire, so apply the mask again.
Robot voice for one listener only · client¶
The same idea on the client, so only you hear the change. It suits a "translator" or "hallucination" effect. Wire otherPlayer.AudioDeviceInput → AudioPitchShifter(0.8) → AudioChorus → a local emitter and keep the original path. Note that you'd hear both versions unless you also move the default wire locally.
Radio / walkie-talkie bus · server + client¶
Each channel is a bus that every tuned-in client builds locally. The server only decides who is transmitting where. That is the pattern the MOTOTRBO R7 uses.
flowchart LR
A[Talker A mic] --> S
B[Talker B mic] --> S
S[Fader: Sum] --> HP[Highpass 300] --> LP[Lowpass 3400] --> G[Gate] --> C[Compressor] --> D[Distortion] --> L[Limiter] --> O[Your ears]
Server: validate and publish who is keyed up.
-- ServerScriptService/RadioServer
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ptt = Instance.new("RemoteEvent")
ptt.Name = "RadioPTT"
ptt.Parent = ReplicatedStorage
local CHANNELS = { Ops = true, Fire = true, EMS = true }
ptt.OnServerEvent:Connect(function(player, channel: string?)
if channel ~= nil and not CHANNELS[channel] then return end
-- your checks here: does the player hold a radio? are they allowed on this channel?
player:SetAttribute("RadioTx", channel)
end)
Client: build the bus once, then wire talkers in and out.
-- StarterPlayerScripts/RadioClient
local Players = game:GetService("Players")
local SoundService = game:GetService("SoundService")
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local AudioUtil = require(ReplicatedStorage.AudioUtil)
local new, wire = AudioUtil.new, AudioUtil.wire
local me = Players.LocalPlayer
local ptt = ReplicatedStorage:WaitForChild("RadioPTT")
local tuned = "Ops"
local bus = new("Folder", { Name = "RadioBus" }, SoundService)
local sum = new("AudioFader", { Name = "Sum" }, bus)
AudioUtil.chain(
sum,
new("AudioFilter", { FilterType = Enum.AudioFilterType.Highpass24dB, Frequency = 300 }, bus),
new("AudioFilter", { FilterType = Enum.AudioFilterType.Lowpass24dB, Frequency = 3400 }, bus),
new("AudioGate", { Threshold = NumberRange.new(-50, -42), Attack = 0.005, Release = 0.15 }, bus),
new("AudioCompressor", { Threshold = -30, Ratio = 8, Attack = 0.003, Release = 0.12, MakeupGain = 14 }, bus),
new("AudioDistortion", { Level = 0.12 }, bus),
new("AudioLimiter", { MaxLevel = -1 }, bus),
new("AudioDeviceOutput", nil, bus)
)
local wires: { [Player]: Wire } = {}
local function refresh(player: Player)
local mic = player:FindFirstChildOfClass("AudioDeviceInput")
local on = player ~= me and mic ~= nil and player:GetAttribute("RadioTx") == tuned
if on and not wires[player] then
wires[player] = wire(mic, sum)
elseif not on and wires[player] then
wires[player]:Destroy()
wires[player] = nil
end
end
local function watch(player: Player)
player:GetAttributeChangedSignal("RadioTx"):Connect(function() refresh(player) end)
player.ChildAdded:Connect(function(child)
if child:IsA("AudioDeviceInput") then refresh(player) end
end)
refresh(player)
end
for _, p in Players:GetPlayers() do watch(p) end
Players.PlayerAdded:Connect(watch)
Players.PlayerRemoving:Connect(function(p)
if wires[p] then wires[p]:Destroy(); wires[p] = nil end
end)
-- Push-to-talk: key the channel and open the mic.
local myMic = me:WaitForChild("AudioDeviceInput") :: AudioDeviceInput
myMic.Muted = true
UserInputService.InputBegan:Connect(function(input, processed)
if processed or input.KeyCode ~= Enum.KeyCode.T then return end
ptt:FireServer(tuned)
myMic.Muted = false
end)
UserInputService.InputEnded:Connect(function(input)
if input.KeyCode ~= Enum.KeyCode.T then return end
ptt:FireServer(nil)
myMic.Muted = true
end)
Notes:
- Talkers are still heard in proximity through their character emitter, as with a real radio. To make radio-only channels, mute proximity for transmitters, or give players their own voice setup with
EnableDefaultVoice = false. - Access lists win. If a talker's mic has an allow-list (team chat), anyone not on it hears nothing on the radio either.
- Loudspeaker radios: replace the
AudioDeviceOutputwith anAudioEmitteron the radio part, so people nearby hear it too. Every client within earshot then has to build that radio's bus, and the R7 does exactly this per radio. - Courtesy tones: wire an
AudioPlayerwith a chirp intosumand play it on key-up.
Duck music while the radio talks · client¶
local duck = AudioUtil.new("AudioCompressor", {
Threshold = -45, Ratio = 6, Attack = 0.01, Release = 0.6,
}, SoundService)
AudioUtil.wire(music, duck) -- music in
AudioUtil.wire(sum, duck, "Sidechain") -- the radio bus is the key
AudioUtil.wire(duck, musicOutput) -- ducked music out
Make sure music is not also wired straight to an output.
Who's talking indicator · client¶
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local AudioUtil = require(game.ReplicatedStorage.AudioUtil)
local meters: { [Player]: AudioAnalyzer } = {}
local function meter(player: Player)
local mic = player:WaitForChild("AudioDeviceInput", 30)
if not mic then return end
local analyzer = AudioUtil.new("AudioAnalyzer", { SpectrumEnabled = false }, mic)
AudioUtil.wire(mic, analyzer)
meters[player] = analyzer
end
for _, p in Players:GetPlayers() do task.spawn(meter, p) end
Players.PlayerAdded:Connect(meter)
Players.PlayerRemoving:Connect(function(p) meters[p] = nil end)
RunService.RenderStepped:Connect(function()
for player, analyzer in meters do
local talking = analyzer.RmsLevel > 0.02
-- show or hide your speaking icon for `player` here
end
end)
RmsLevel does not fire Changed, so poll it. Spectra are never available for microphones.
World and effects¶
Underwater muffle · client¶
Set SoundService › ListenerLocation to None and build your own ears, so you have a place to insert effects:
local Players = game:GetService("Players")
local SoundService = game:GetService("SoundService")
local AudioUtil = require(game.ReplicatedStorage.AudioUtil)
local listener = AudioUtil.new("AudioListener", nil, workspace.CurrentCamera)
local water = AudioUtil.new("AudioEqualizer", {
Bypass = true,
LowGain = 0, MidGain = -10, HighGain = -40,
MidRange = NumberRange.new(300, 1200),
}, SoundService)
AudioUtil.chain(listener, water, AudioUtil.new("AudioDeviceOutput", nil, SoundService))
local function onCharacter(character: Model)
local humanoid = character:WaitForChild("Humanoid") :: Humanoid
humanoid.StateChanged:Connect(function(_, new)
water.Bypass = new ~= Enum.HumanoidStateType.Swimming
end)
end
Players.LocalPlayer.CharacterAdded:Connect(onCharacter)
if Players.LocalPlayer.Character then onCharacter(Players.LocalPlayer.Character) end
Intercom / baby monitor with interaction groups · client¶
A microphone in one room plays out of a speaker in another, without feedback:
local AudioUtil = require(game.ReplicatedStorage.AudioUtil)
local SoundService = game:GetService("SoundService")
-- Room mic: hears the normal world (group "").
local roomMic = AudioUtil.new("AudioListener", nil, workspace.Nursery.MicPart)
local tinny = AudioUtil.new("AudioFilter", { FilterType = Enum.AudioFilterType.Bandpass, Frequency = 1500, Q = 0.8 }, SoundService)
-- Speaker: emits into a separate group, so roomMic can't hear it (no feedback loop).
local speaker = AudioUtil.new("AudioEmitter", { AudioInteractionGroup = "Intercom" }, workspace.Kitchen.Speaker)
AudioUtil.chain(roomMic, tinny, speaker)
-- Second set of ears on the camera that only hears the "Intercom" group.
local intercomEars = AudioUtil.new("AudioListener", { AudioInteractionGroup = "Intercom" }, workspace.CurrentCamera)
AudioUtil.wire(intercomEars, AudioUtil.new("AudioDeviceOutput", nil, SoundService))
Music visualizer · client¶
local RunService = game:GetService("RunService")
local AudioUtil = require(game.ReplicatedStorage.AudioUtil)
local analyzer = AudioUtil.new("AudioAnalyzer", {
SpectrumEnabled = true,
WindowSize = Enum.AudioWindowSize.Medium,
}, music)
AudioUtil.wire(music, analyzer) -- tap the music; this doesn't change what you hear
local BANDS = #bars -- an array of Frames you laid out in a ScreenGui
RunService.RenderStepped:Connect(function()
local spectrum = analyzer:GetSpectrum() -- bins evenly spaced 0..24,000 Hz
local n = #spectrum
if n == 0 then return end
for b = 1, BANDS do
-- log-spaced bands from 40 Hz to 16 kHz
local lo = 40 * (16000 / 40) ^ ((b - 1) / BANDS)
local hi = 40 * (16000 / 40) ^ (b / BANDS)
local i0 = math.clamp(math.floor(lo / 24000 * n) + 1, 1, n)
local i1 = math.clamp(math.floor(hi / 24000 * n) + 1, i0, n)
local sum = 0
for i = i0, i1 do sum += spectrum[i] end
local db = 20 * math.log10(math.max(sum / (i1 - i0 + 1), 1e-5))
local h = math.clamp((db + 60) / 60, 0, 1)
bars[b].Size = UDim2.new(bars[b].Size.X, UDim.new(h, 0))
end
end)
For a static waveform (a seek bar or a track preview), use music:GetWaveformAsync(NumberRange.new(0, music.TimeLength), 200) once instead.
Speech¶
Talking NPC with text-to-speech · client¶
local AudioUtil = require(game.ReplicatedStorage.AudioUtil)
local head = workspace:WaitForChild("Guide"):WaitForChild("Head")
local tts = AudioUtil.new("AudioTextToSpeech", { VoiceId = "11", Speed = 1, Volume = 1.5 }, head)
local mouth = AudioUtil.new("AudioEmitter", {
DistanceAttenuationMode = Enum.DistanceAttenuationMode.InverseTapered,
DistanceAttenuationBounds = NumberRange.new(6, 60),
}, head)
AudioUtil.wire(tts, mouth)
local function say(text: string)
tts:Pause()
tts.TimePosition = 0
tts.Text = text -- 300 characters max; AutoLocalize translates per player
if tts:LoadAsync() == Enum.AssetFetchStatus.Success then
tts:Play()
tts.Ended:Wait()
end
end
-- Longer scripts: speak sentence by sentence to stay under 300 chars.
local function sayLong(text: string)
for sentence in string.gmatch(text, "[^%.!?]+[%.!?]*") do
say(string.sub(sentence, 1, 300))
end
end
Running TTS on the client means each player gets their own language through AutoLocalize, and the per-experience request budget scales with player count. If the text comes from a player, filter it on the server first with TextService:FilterStringAsync before anyone hears it.
Voice commands with speech-to-text · client + server¶
Studio setup: VoiceChatService.UseAudioApi = Enabled, microphone on in Experience Settings. Optionally set EnableDefaultVoice = false so commands aren't broadcast as voice.
-- StarterPlayerScripts/VoiceCommands
local Players = game:GetService("Players")
local SoundService = game:GetService("SoundService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local AudioUtil = require(ReplicatedStorage.AudioUtil)
local remote = ReplicatedStorage:WaitForChild("VoiceCommand")
local me = Players.LocalPlayer
local mic = me:FindFirstChildOfClass("AudioDeviceInput")
or AudioUtil.new("AudioDeviceInput", { Player = me }, SoundService) -- local Player assignment is allowed for LocalPlayer
local stt = AudioUtil.new("AudioSpeechToText", { Enabled = true }, SoundService)
AudioUtil.wire(mic, stt)
local COMMANDS = {
["open sesame"] = "OpenDoor",
["lights on"] = "LightsOn",
["lights off"] = "LightsOff",
}
local function normalize(s: string): string
s = s:lower():gsub("%p", ""):gsub("%s+", " ")
return (s:gsub("^ ", ""):gsub(" $", ""))
end
stt:GetPropertyChangedSignal("Text"):Connect(function()
local heard = normalize(stt.Text)
if heard == "" then return end
stt.Text = "" -- clear it so the same phrase can fire again
local padded = " " .. heard .. " "
for phrase, action in COMMANDS do
if string.find(padded, " " .. phrase .. " ", 1, true) then
remote:FireServer(action) -- the server must validate it
break
end
end
end)
Each utterance is capped at 10 seconds, and the experience shares 1 + 5 × CCU requests a minute. Pair it with push-to-talk so the recognizer isn't chewing through background chatter.