Skip to content

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 AudioDeviceOutput with an AudioEmitter on 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 AudioPlayer with a chirp into sum and 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.