Seeking Help: Roblox Audio API - Stereo Issues with AudioChannelSplitter

Hello, everyone.

I’m working on a speaker system using the new Roblox Audio API and I’m running into some unexpected issues.

I’m trying to get my AudioChannelSplitter to work correctly with a stereo audio source, but the output from my speakers remains mono. I’ve been unable to figure out what’s wrong, despite trying several things.

I’ve already:

  • Checked multiple online forums.
  • Asked for help from other AI assistants (Gemini, ChatGPT).

I suspect I might be wiring something incorrectly, but I’m not sure what it is.

Below is the relevant code. The first script is the main server-side script that creates the audio effects and wires, and the second is a local script that creates the audio output and the AudioChannelSplitter.

Thank you for any help you can provide!

**(Main Script)**
-- | Services |
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

-- | Configuration & Constants |
local ADMIN_USERS = {
	["Emils_Studio"] = true,
	["RealFauxPlayzs"] = true,
}
local RESPONSE_PREFIX = "[Sonic Havoc] 🔊 | "

local sonicHavoc = Workspace:WaitForChild("[Sonic Havoc] Workspace")
local worker = sonicHavoc:WaitForChild("Worker")
local config = worker:WaitForChild("Configuration")
local remotes = ReplicatedStorage:WaitForChild("SonicHavocRemotes")

-- | Module Imports |
local AudioSets = require(worker:WaitForChild("AudioSets"))
local Wire = require(worker:WaitForChild("Modules"):WaitForChild("Wire"))
local PRESETS = require(worker:WaitForChild("Modules"):WaitForChild("Presets"))

-- | Folder Setup |
local wiresFolder = sonicHavoc:FindFirstChild("Wires")
local effectWires = wiresFolder:FindFirstChild("Effects")
local speakerWires = wiresFolder:FindFirstChild("Speaker")

-- | Channel Definitions |
local CHANNELS = {
	MID_L = { group = config.MID_L.Value, isSub = false, side = "left" },
	MID_R = { group = config.MID_R.Value, isSub = false, side = "right" },
	SUB = { group = config.SUB.Value, isSub = true, side = "mono" },
}

local masterVolume = 0.5
local audioGraph = { player = nil, masterMixer = nil, splitter = nil, channels = {}, emitters = {}, wires = {}, sound = nil }
local channelState = {}

local function defaultFX(isSub)
	return {
		eq = {
			LowGain = isSub and 1.5 or 0,
			MidGain = isSub and 0.5 or 0,
			HighGain = isSub and -4 or 0,
			MidRange = isSub and 200
		},
		compressor = {
			Threshold = -18,
			Ratio = 3.0,
			Attack = 0.03,
			Release = 0.15,
			MakeupGain = 1,
		},
		limiter = {
			MaxLevel = -0.3,
			Release = 0.15,
		},
		reverb = {
			Enabled = not isSub,
			DryLevel = 0,
			WetLevel = -22,
			DecayTime = 1.8,
			Density = 1,
			Diffusion = 1,
			DecayRatio = 0.5,
			HighCutFrequency = 18000,
		},
		distortion = {
			Enabled = false,
			Level = 0.05,
		},
		echo = {
			Enabled = false,
			DelayTime = 0.3,
			Feedback = 0.3,
			WetLevel = -12,
			DryLevel = 0,
			RampTime = 0,
		}
	}
end

for key, cfg in pairs(CHANNELS) do
	channelState[key] = {
		volume = 1.0,
		solo = false,
		eq = defaultFX(cfg.isSub).eq,
		compressor = defaultFX(cfg.isSub).compressor,
		limiter = defaultFX(cfg.isSub).limiter,
		reverb = defaultFX(cfg.isSub).reverb,
		distortion = defaultFX(cfg.isSub).distortion,
		echo = defaultFX(cfg.isSub).echo,
	}
end

-- | Helper Functions |
local function shallowCopy(t)
	if type(t) ~= "table" then return t end
	local out = {}
	for k, v in pairs(t) do
		if type(v) == "table" then
			out[k] = shallowCopy(v)
		else
			out[k] = v
		end
	end
	return out
end

local function setupAudioGraph()
	audioGraph.player = sonicHavoc.Audio:FindFirstChild("Player") and sonicHavoc.Audio.Player:FindFirstChild("MasterPlayer")
	audioGraph.sound = sonicHavoc.Audio:FindFirstChild("Sound") and sonicHavoc.Audio.Sound:FindFirstChild("audio")
	if not audioGraph.player then
		warn(RESPONSE_PREFIX .. "AudioPlayer not found! Cannot create audio graph.")
		return
	end

	local effectFolder = sonicHavoc.Audio:FindFirstChild("Effect") or Instance.new("Folder", sonicHavoc.Audio)
	effectFolder.Name = "Effect"

	local masterEQ = Instance.new("AudioEqualizer")
	local masterLimiter = Instance.new("AudioLimiter")
	masterEQ.Parent = effectFolder
	masterLimiter.Parent = effectFolder
	audioGraph.masterEQ = masterEQ
	audioGraph.masterLimiter = masterLimiter

	local function connect(src, tgt)
		if src and tgt then
			local w = Wire.new(src, tgt)
			if w then
				w.Parent = effectWires
				table.insert(audioGraph.wires, w)
			end
		end
	end

	connect(audioGraph.player, masterEQ)
	connect(masterEQ, masterLimiter)

	for name, cfg in pairs(CHANNELS) do
		local chain = {}
		chain.fader = Instance.new("AudioFader")
		chain.compressor = Instance.new("AudioCompressor")
		chain.crossover = Instance.new("AudioEqualizer")
		chain.eq = Instance.new("AudioEqualizer")
		chain.distortion = Instance.new("AudioDistortion")
		chain.reverb = Instance.new("AudioReverb")
		chain.echo = Instance.new("AudioEcho")
		chain.limiter = Instance.new("AudioLimiter")

		local channelFolder = effectFolder:FindFirstChild(name) or Instance.new("Folder", effectFolder)
		channelFolder.Name = name
		for _, eff in pairs(chain) do eff.Parent = channelFolder end

		connect(masterLimiter, chain.fader)
		connect(chain.fader, chain.compressor)
		connect(chain.compressor, chain.crossover)
		connect(chain.crossover, chain.eq)
		connect(chain.eq, chain.distortion)
		connect(chain.distortion, chain.reverb)
		connect(chain.reverb, chain.echo)
		connect(chain.echo, chain.limiter)

		audioGraph.channels[name] = chain
		audioGraph.emitters[name] = {}
	end

	-- Attach emitters
	for name, cfg in pairs(CHANNELS) do
		local chain = audioGraph.channels[name]
		local groups = {}
		if cfg.isSub then
			table.insert(groups, config.SUB.Value)
		else
			table.insert(groups, cfg.group)
		end

		for _, group in ipairs(groups) do
			for _, model in ipairs(group:GetChildren()) do
				local attachment = model:FindFirstChild("Audio") and model.Audio
				if attachment then
					local emitter = Instance.new("AudioEmitter")
					emitter.Name = model.Name.."_Emitter"
					emitter.Parent = attachment

					local curve = {
						[config.AudioMinDistance.Value] = 1,
						[config.AudioMaxDistance.Value / 2] = 0.5,
						[config.AudioMaxDistance.Value] = 0
					}
					emitter:SetDistanceAttenuation(curve)
					table.insert(audioGraph.emitters[name], emitter)

					local emitterWire = Wire.new(chain.limiter, emitter)
					if emitterWire then
						emitterWire.Parent = speakerWires
						table.insert(audioGraph.wires, emitterWire)
					end
				end
			end
		end
	end
end


local function updateAudioGraph()
	if not audioGraph.player then return end

	local anySolo = false
	for _, st in pairs(channelState) do if st.solo then anySolo = true break end end

	local loudnessComp = math.max(0, 3 * (1 - masterVolume))

	for name, cfg in pairs(CHANNELS) do
		local state = channelState[name]
		local chain = audioGraph.channels[name]
		if not state or not chain then continue end

		local vol = state.volume
		if anySolo and not state.solo then vol = 0 end
		chain.fader.Volume = math.clamp(vol * masterVolume, 0, 2.0)

		chain.compressor.Bypass = false
		chain.compressor.Threshold = state.compressor.Threshold
		chain.compressor.Ratio = state.compressor.Ratio
		chain.compressor.Attack = state.compressor.Attack
		chain.compressor.Release = state.compressor.Release
		chain.compressor.MakeupGain = state.compressor.MakeupGain

		if cfg.isSub then
			chain.crossover.HighGain, chain.crossover.MidGain, chain.crossover.LowGain = -80, -12, 0
			chain.crossover.MidRange = NumberRange.new(200, 200)
		else
			chain.crossover.HighGain, chain.crossover.MidGain, chain.crossover.LowGain = 0, 0, -80
		end

		-- Distortion
		chain.distortion.Bypass = not state.distortion.Enabled
		chain.distortion.Level = state.distortion.Level * (0.8 + state.volume * 0.2)

		-- EQ
		chain.eq.LowGain = state.eq.LowGain + (cfg.isSub and 0 or loudnessComp)
		chain.eq.MidGain = state.eq.MidGain
		chain.eq.HighGain = state.eq.HighGain + (cfg.isSub and 0 or loudnessComp * 0.7)

		-- Reverb
		chain.reverb.Bypass = not state.reverb.Enabled
		chain.reverb.DryLevel = state.reverb.DryLevel
		chain.reverb.WetLevel = state.reverb.WetLevel
		chain.reverb.DecayTime = state.reverb.DecayTime
		chain.reverb.Density = state.reverb.Density
		chain.reverb.Diffusion = state.reverb.Diffusion
		chain.reverb.DecayRatio = state.reverb.DecayRatio
		chain.reverb.HighCutFrequency = state.reverb.HighCutFrequency

		-- Echo
		chain.echo.Bypass = not state.echo.Enabled
		chain.echo.DelayTime = state.echo.DelayTime
		chain.echo.Feedback = state.echo.Feedback
		chain.echo.WetLevel = state.echo.WetLevel
		chain.echo.DryLevel = state.echo.DryLevel
		chain.echo.RampTime = state.echo.RampTime

		-- Limiter
		chain.limiter.Bypass = false
		chain.limiter.MaxLevel = state.limiter.MaxLevel
		chain.limiter.Release = state.limiter.Release
	end
end

local function handleCommand(player, cmd, args)
	cmd = cmd:lower()
	local sub = args[1] and args[1]:lower()

	if cmd == "/play" and sub == "set" then
		local setName = args[2]
		local setData = AudioSets.Sets[setName]
		if not setData or not setData.Tracks or #setData.Tracks == 0 then
			print(RESPONSE_PREFIX.."Set not found or empty.")
			return
		end
		local assetId = tostring(setData.Tracks[1])
		pcall(function()
			audioGraph.player.AssetId = "rbxassetid://" .. assetId
			audioGraph.player.PlaybackSpeed = setData.Speed or 1
			audioGraph.player.TimePosition = 0
			audioGraph.player:Play()
			if audioGraph.sound then
				audioGraph.sound.SoundId = "rbxassetid://" .. assetId
				audioGraph.sound.Volume = 0
				audioGraph.sound:Play()
			end
		end)
		print(RESPONSE_PREFIX.."Playing set '"..setName.."' (by "..player.Name..")")

	elseif cmd == "/play" then
		local assetId = tostring(args[1])
		pcall(function()
			audioGraph.player.AssetId = "rbxassetid://" .. assetId
			audioGraph.player.PlaybackSpeed = 1
			audioGraph.player.TimePosition = 0
			audioGraph.player:Play()
			if audioGraph.sound then
				audioGraph.sound.SoundId = "rbxassetid://" .. assetId
				audioGraph.sound.Volume = 0
				audioGraph.sound:Play()
			end
		end)
		print(RESPONSE_PREFIX.."Playing AssetId "..assetId.." (by "..player.Name..")")

	elseif cmd == "/stop" then
		if audioGraph.player then audioGraph.player:Stop() end
		if audioGraph.sound then audioGraph.sound:Stop() end
		print(RESPONSE_PREFIX.."Playback stopped by "..player.Name)

	elseif cmd == "/volume" then
		local volArg = tonumber(args[1])
		if not volArg then print(RESPONSE_PREFIX.."Usage: /volume [0-100]"); return end
		masterVolume = math.clamp(volArg, 0, 100) / 100
		updateAudioGraph()
		print(RESPONSE_PREFIX..string.format("Master volume set to %d%% by %s", volArg, player.Name))

	elseif cmd == "/preset" then
		local presetName = args[1] and args[1]:lower()
		local preset = PRESETS[presetName]
		if not preset then
			local available = {}
			for name in pairs(PRESETS) do table.insert(available, name) end
			print(RESPONSE_PREFIX.."Invalid preset. Available: "..table.concat(available, ", "))
			return
		end

		-- Apply preset
		for key, state in pairs(channelState) do
			local cfg = CHANNELS[key]
			for fxType, params in pairs(preset) do
				if fxType == "eq" and not cfg.isSub then
					for k, v in pairs(params) do state.eq[k] = v end
				elseif fxType == "subEq" and cfg.isSub then
					for k, v in pairs(params) do state.eq[k] = v end
				elseif state[fxType] then
					for k, v in pairs(params) do state[fxType][k] = v end
				end
			end
		end

		updateAudioGraph()
		print(RESPONSE_PREFIX..preset.response.." (Set by "..player.Name..")")
	end
end

-- | Event Listeners |
local function onPlayerAdded(player)
	player.Chatted:Connect(function(msg)
		if not ADMIN_USERS[player.Name] then return end
		if msg:sub(1,1) ~= "/" then return end

		local args = msg:split(" ")
		local cmd = table.remove(args,1)
		handleCommand(player, cmd, args)
	end)
end

-- | Initialization |
setupAudioGraph()
task.wait(0.2)
updateAudioGraph()

Players.PlayerAdded:Connect(onPlayerAdded)
for _, p in ipairs(Players:GetPlayers()) do onPlayerAdded(p) end

-- Sync hidden sound for analysis
RunService.Heartbeat:Connect(function()
	if audioGraph.player and audioGraph.sound and audioGraph.player.IsPlaying then
		if not audioGraph.sound.IsPlaying then
			audioGraph.sound.SoundId = audioGraph.player.AssetId
			audioGraph.sound.PlaybackSpeed = audioGraph.player.PlaybackSpeed
			audioGraph.sound.Volume = 0
			audioGraph.sound:Play()
		end
		if math.abs(audioGraph.player.TimePosition - audioGraph.sound.TimePosition) > 0.15 then
			audioGraph.sound.TimePosition = audioGraph.player.TimePosition
		end
	end
end)
**(ListenerClient LocalScript)**
-- | Services |
local Workspace = game:GetService("Workspace")
local Players = game:GetService("Players")
local LocalPlayer = Players.LocalPlayer

local function getLayout()
	local sonicHavoc = Workspace:FindFirstChild("[Sonic Havoc] Workspace")
	if not sonicHavoc then return "Stereo" end
	local worker = sonicHavoc:FindFirstChild("Worker")
	if not worker then return "Stereo" end
	local config = worker:FindFirstChild("Configuration")
	if not config then return "Stereo" end
	local v = config:FindFirstChild("ChannelLayout")
	if v and typeof(v.Value) == "string" then
		local s = v.Value:lower()
		if s == "quad" or s == "quadraphonic" then return "Quad" end
	end
	return "Stereo"
end
local CHANNEL_LAYOUT = getLayout()

local camera = Workspace.CurrentCamera
if not camera then
	Workspace:GetPropertyChangedSignal("CurrentCamera"):Wait()
	camera = Workspace.CurrentCamera
end

local audioContainer = Instance.new("Folder")
audioContainer.Name = "ClientAudioSystem"
audioContainer.Parent = LocalPlayer:WaitForChild("PlayerScripts")

local WireContainer = Instance.new("Folder")
WireContainer.Name = "Wires"
WireContainer.Parent = audioContainer

local function wireUp(source, target, sourceName, targetName)
	local wire = Instance.new("Wire")
	wire.SourceInstance = source
	wire.TargetInstance = target
	if sourceName then wire.SourceName = sourceName end
	if targetName then wire.TargetName = targetName end
	wire.Parent = WireContainer
	return wire
end

local listener = Instance.new("AudioListener")
local splitter = Instance.new("AudioChannelSplitter")
local mixer = Instance.new("AudioChannelMixer")
local output = Instance.new("AudioDeviceOutput")

listener.Parent = camera
splitter.Parent = audioContainer
mixer.Parent = audioContainer
output.Parent = audioContainer

pcall(function()
	if CHANNEL_LAYOUT == "Quad" then
		splitter.Layout = Enum.AudioChannelLayout.Quad
		mixer.Layout = Enum.AudioChannelLayout.Quad
	else
		splitter.Layout = Enum.AudioChannelLayout.Stereo
		mixer.Layout = Enum.AudioChannelLayout.Stereo
	end
end)

wireUp(listener, splitter)

wireUp(mixer, output)

local leftFader = Instance.new("AudioFader")
leftFader.Name = "LeftChannelFader"
leftFader.Volume = 1.0
leftFader.Parent = audioContainer

local rightFader = Instance.new("AudioFader")
rightFader.Name = "RightChannelFader"
rightFader.Volume = 1.0
rightFader.Parent = audioContainer

print("[Sonic Havoc] Created Left and Right channel faders.")

if mixer.Layout == Enum.AudioChannelLayout.Stereo then
	wireUp(splitter, leftFader, "Left")
	wireUp(leftFader, mixer, nil, "Left")
	wireUp(splitter, rightFader, "Right")
	wireUp(rightFader, mixer, nil, "Right")

	print("[Sonic Havoc] Wired Stereo signal chain with effects.")
else
	wireUp(splitter, mixer, "FrontLeft", "FrontLeft")
	wireUp(splitter, mixer, "FrontRight", "FrontRight")
	wireUp(splitter, mixer, "BackLeft", "BackLeft")
	wireUp(splitter, mixer, "BackRight", "BackRight")
end