--[[ | This file was obtained through the combined efforts | of Madbluntz & Plymouth Antiquarian Society. | | Credits: lifestorm, Gregory Wayne Rossel JR., | Maloy, DrPepper10 @ RIP, Atle! | | Visit for more: https://plymouth.thetwilightzone.ru/ --]] local webaudio = _G.webaudio or {} _G.webaudio = webaudio if me then webaudio.debug = true end webaudio.sample_rate = nil webaudio.speed_of_sound = 340.29 -- metres per webaudio.buffer_size = CreateClientConVar("webaudio_buffer_size", "2048", true) local function logn(str) MsgC(Color(0, 255, 0), "[pac webaudio] ") MsgC(Color(255, 255, 255), str) Msg("\n") end local function dprint(str) if webaudio.debug then logn(str) end end cvars.AddChangeCallback("webaudio_buffer_size", function(_,_,val) dprint("buffer size changed to " .. val) webaudio.Shutdown() webaudio.Initialize() end) if webaudio.browser_panel and webaudio.browser_panel:IsValid() then webaudio.browser_panel:Remove() webaudio.browser_panel = nil end webaudio.browser_state = "uninitialized" webaudio.volume = 1 local script_queue local function run_javascript(code, stream) if stream and not stream:IsReady() then stream.js_queue = stream.js_queue or {} table.insert(stream.js_queue, code) return end if script_queue then table.insert(script_queue, code) else if code ~= "" then --print("|" .. code .. "|") webaudio.browser_panel:RunJavascript(code) end end end local function queue_javascript() script_queue = script_queue or {} end local function execute_javascript() if script_queue then local str = table.concat(script_queue, "\n") script_queue = nil run_javascript(str) end end do local last_eye_pos local last_eye_pos_time function webaudio.Update() if webaudio.browser_state ~= "initialized" then if webaudio.browser_state ~= "initializing" then webaudio.Initialize() end return end if not system.HasFocus() and GetConVar("snd_mute_losefocus"):GetBool() then webaudio.SetVolume(0) else webaudio.SetVolume(GetConVar("volume"):GetFloat()) end local time = RealTime() last_eye_pos = last_eye_pos or webaudio.eye_pos last_eye_pos_time = last_eye_pos_time or (time - FrameTime()) webaudio.eye_velocity = (webaudio.eye_pos - last_eye_pos) / (time - last_eye_pos_time) last_eye_pos = webaudio.eye_pos last_eye_pos_time = time for streamId, stream in pairs(webaudio.streams) do if stream:IsValid() then stream:Think() else webaudio.streams[streamId] = nil end end end end function webaudio.Shutdown() webaudio.browser_state = "uninitialized" if webaudio.browser_panel then webaudio.browser_panel:Remove() end webaudio.browser_panel = nil hook.Remove("RenderScene", "pac_webaudio2") hook.Remove("Think", "pac_webaudio2") end function webaudio.Initialize() if webaudio.browser_state ~= "uninitialized" then return end webaudio.browser_state = "initializing" if webaudio.browser_panel then webaudio.browser_panel:Remove() end webaudio.browser_panel = vgui.Create("DHTML") webaudio.browser_panel:SetVisible(false) webaudio.browser_panel:SetPos(ScrW(), ScrH()) webaudio.browser_panel:SetSize(1, 1) local last_message = nil webaudio.browser_panel.ConsoleMessage = function(self, message) -- why does awesomium crash in the first place? if msg == "Uncaught ReferenceError: lua is not defined" then webaudio.browser_state = "uninitialized" end if last_message ~= message then last_message = message dprint(message) end end webaudio.browser_panel:AddFunction("lua", "print", dprint) webaudio.browser_panel:AddFunction("lua", "message", function(typ, ...) local args = {} for i = 1, select("#", ...) do args[i] = tostring(select(i, ...)) end dprint(typ .. " " .. table.concat(args, ", ")) if typ == "initialized" then webaudio.browser_state = "initialized" webaudio.sample_rate = args[1] or -1 elseif typ == "stream" then local stream = webaudio.GetStream(tonumber(args[2]) or 0) if stream:IsValid() then stream:HandleBrowserMessage(args[1], unpack(args, 3, table.maxn(args))) end end end) local js = ([==[ /*jslint bitwise: true */ window.onerror = function(description, url, line) { dprint("Unhandled exception at line " + line + ": " + description); }; function dprint(str) { ]==] .. (webaudio.debug and "lua.print(str);" or "") .. [==[ } var audio; var gain; var processor; var streams = new Object(); var streams_array = []; function open() { if(audio) { audio.destination.disconnect(); } if (typeof AudioContext != "undefined") { audio = new AudioContext(); processor = audio.createScriptProcessor(]==] .. webaudio.buffer_size:GetInt() .. [==[, 2, 2); gain = audio.createGain(); compressor = audio.createDynamicsCompressor() } else { audio = new webkitAudioContext(); processor = audio.createJavaScriptNode(]==] .. webaudio.buffer_size:GetInt() .. [==[, 2, 2); gain = audio.createGainNode(); compressor = audio.createDynamicsCompressor() } processor.onaudioprocess = function(event) { var output_left = event.outputBuffer.getChannelData(0); var output_right = event.outputBuffer.getChannelData(1); for(var i = 0; i < event.outputBuffer.length; ++i) { output_left[i] = 0; output_right[i] = 0; } for(var i = 0; i < streams_array.length; ++i) { var stream = streams_array[i]; var buffer_length = stream.buffer.length; var buffer_left = stream.buffer.getChannelData(0); var buffer_right = stream.buffer.numberOfChannels == 1 ? buffer_left : stream.buffer.getChannelData(1); if (stream.use_smoothing) { stream.speed_smooth = stream.speed_smooth + (stream.speed - stream.speed_smooth) * 1; stream.vol_left_smooth = stream.vol_left_smooth + (stream.vol_left - stream.vol_left_smooth ) * 1; stream.vol_right_smooth = stream.vol_right_smooth + (stream.vol_right - stream.vol_right_smooth) * 1; } else { stream.speed_smooth = stream.speed; stream.vol_left_smooth = stream.vol_left_smooth; stream.vol_right_smooth = stream.vol_right_smooth; } if(!stream.use_echo && (stream.paused || (stream.vol_left < 0.001 && stream.vol_right < 0.001))) { continue; } var echol; var echor; if (stream.use_echo && stream.echo_buffer) { echol = stream.echo_buffer.getChannelData(0); echor = stream.echo_buffer.getChannelData(1); } var sml = 0; var smr = 0; for(var j = 0; j < event.outputBuffer.length; ++j) { if (stream.paused || stream.max_loop > 0 && stream.position >= (buffer_length * stream.max_loop) - 1) { stream.done_playing = true; if (!stream.paused) { stream.paused = true; } if (!stream.use_echo) { break; } } else { stream.done_playing = false; } var index = (stream.position >> 0) % buffer_length; if (stream.reverse) { index = -index + buffer_length; } var left = 0; var right = 0; if (!stream.done_playing) { // filters if (stream.filter_type == 0) { // None left = buffer_left[index] * stream.vol_both; right = buffer_right[index] * stream.vol_both; } else { sml = sml + (buffer_left[index] - sml) * stream.filter_fraction; smr = smr + (buffer_right[index] - smr) * stream.filter_fraction; if (stream.filter_type == 1) { // Low pass left = sml * stream.vol_both; right = smr * stream.vol_both; } else if (stream.filter_type == 2) { // High pass left = (buffer_left[index] - sml) * stream.vol_both; right = (buffer_right[index] - smr) * stream.vol_both; } } left = Math.min(Math.max(left, -1), 1) * stream.vol_left_smooth; right = Math.min(Math.max(right, -1), 1) * stream.vol_right_smooth; } if (stream.lfo_volume_time) { var res = (Math.sin((stream.position/audio.sampleRate)*10*stream.lfo_volume_time)*stream.lfo_volume_amount); left *= res; right *= res; } if (stream.use_echo) { var echo_index = (stream.position >> 0) % stream.echo_delay; echol[echo_index] = echol[echo_index] * stream.echo_feedback + left; echor[echo_index] = echor[echo_index] * stream.echo_feedback + right; output_left[j] += echol[echo_index]; output_right[j] += echor[echo_index]; } else { output_left[j] += left; output_right[j] += right; } var speed = stream.speed_smooth; if (stream.lfo_pitch_time) { speed -= (Math.sin((stream.position/audio.sampleRate)*10*stream.lfo_pitch_time)*stream.lfo_pitch_amount); speed += Math.pow(stream.lfo_pitch_amount*0.5,2); } stream.position += speed; var max = 1; output_left[j] = Math.min(Math.max(output_left[j], -max), max); output_right[j] = Math.min(Math.max(output_right[j], -max), max); if (!isFinite(output_left[j])) { output_left[j] = 0 } if (!isFinite(output_right[j])) { output_right[j] = 0 } } } }; processor.connect(compressor); compressor.connect(gain); gain.connect(audio.destination); lua.message("initialized", audio.sampleRate); } function close() { if(audio) { audio.destination.disconnect(); audio = null; lua.message("uninitialized"); } } var buffer_cache = new Object(); function download_buffer(url, callback, skip_cache, id) { if (!skip_cache && buffer_cache[url]) { callback(buffer_cache[url]); return; } var request = new XMLHttpRequest(); request.open("GET", url); request.responseType = "arraybuffer"; request.send(); request.onload = function() { dprint("decoding " + url + " " + request.response.byteLength + " ..."); audio.decodeAudioData(request.response, function(buffer) { dprint("decoded " + url + " successfully"); callback(buffer); buffer_cache[url] = buffer; }, function(err) { dprint("decoding error " + url + " " + err.message); lua.message("stream", "call", id, "OnError", "decoding failed", err.message); } ); }; request.onprogress = function(event) { dprint("downloading " + (event.loaded / event.total) * 100); }; request.onerror = function() { dprint("downloading " + url + " errored"); lua.message("stream", "call", id, "OnError", "download failed: ", request.responseText); }; } function CreateStream(url, id, skip_cache) { dprint("Loading " + url); download_buffer(url, function(buffer) { var stream = {}; stream.id = id; stream.position = 0; stream.buffer = buffer; stream.url = url; stream.speed = 1; // 1 = normal pitch stream.max_loop = 1; // -1 = inf stream.vol_both = 1; stream.vol_left = 1; stream.vol_right = 1; stream.paused = true; stream.use_smoothing = true; stream.echo_volume = 0; stream.filter_type = 0; stream.filter_fraction = 1; stream.done_playing = false; stream.use_echo = false; stream.echo_feedback = 0.75; stream.echo_buffer = false; stream.vol_left_smooth = 0; stream.vol_right_smooth = 0; stream.speed_smooth = stream.speed; stream.play = function(stop, position) { dprint("play " + stop + position) if(position !== undefined) { stream.position = position; } stream.paused = !stop; }; stream.usefft = function(enable) { // later }; stream.useEcho = function(b) { stream.use_echo = b; if (b) { stream.setEchoDelay(stream.echo_delay); } else { stream.echo_buffer = undefined; } }; stream.setEchoDelay = function(x) { if(stream.use_echo && (!stream.echo_buffer || (x != stream.echo_buffer.length))) { var size = 1; while((size <<= 1) < x); stream.echo_buffer = audio.createBuffer(2, size, audio.sampleRate); } stream.echo_delay = x; }; streams[id] = stream; streams_array.push(stream); dprint("created stream[" + id + "][" + stream.url + "]"); lua.message("stream", "loaded", id, buffer.length); if (]==] .. (webaudio.debug and "true" or "false") .. [==[) { var size = 0, key; for (key in streams) { if (streams.hasOwnProperty(key)) size++; } dprint("total stream count " + size) } }, skip_cache, id); } function DestroyStream(id) { var stream = streams[id]; if (stream) { dprint("destroying stream[" + id + "][" + stream.url + "]"); delete streams[id]; delete buffer_cache[stream.url]; var i = streams_array.indexOf(stream); streams_array.splice(i, 1); if (]==] .. (webaudio.debug and "true" or "false") .. [==[) { var size = 0, key; for (key in streams) { if (streams.hasOwnProperty(key)) size++; } dprint("total stream count " + size) } } } open(); ]==]) webaudio.browser_panel.OnFinishLoadingDocument = function(self) self.OnFinishLoadingDocument = nil dprint("OnFinishLoadingDocument") webaudio.browser_panel:RunJavascript(js) end file.Write("pac_webaudio2_blankhtml.txt", "") webaudio.browser_panel:OpenURL("asset://garrysmod/data/pac_webaudio2_blankhtml.txt") webaudio.eye_pos = Vector() webaudio.eye_ang = Angle() hook.Add("RenderScene", "pac_webaudio2", function(pos, ang) webaudio.eye_pos = pos webaudio.eye_ang = ang end) hook.Add("Think", "pac_webaudio2", webaudio.Update) end -- Audio function webaudio.SetVolume(vol) if webaudio.volume ~= vol then webaudio.volume = vol run_javascript(string.format("gain.gain.value = %f", vol)) end end webaudio.streams = setmetatable({}, {__mode = "kv"}) do local META = {} META.__index = META local function DECLARE_PROPERTY(name, default, javascriptSetterCode, filterFunction) META[name] = default META["Set" .. name] = function(self, value) if filterFunction then value = filterFunction(value, self) end self[name] = value if javascriptSetterCode then self:Call(javascriptSetterCode, value) end end META["Get" .. name] = function(self, ...) return self[name] end end DECLARE_PROPERTY("Loaded", false) DECLARE_PROPERTY("Paused", true) DECLARE_PROPERTY("SampleCount", 0) DECLARE_PROPERTY("MaxLoopCount", nil) DECLARE_PROPERTY("Panning", 0) DECLARE_PROPERTY("Volume", 1) DECLARE_PROPERTY("AdditiveVolumeFraction", 0) DECLARE_PROPERTY("3D", false) DECLARE_PROPERTY("Doppler", true) DECLARE_PROPERTY("SourceEntity", NULL) DECLARE_PROPERTY("SourcePosition", nil) DECLARE_PROPERTY("LastSourcePosition", nil) DECLARE_PROPERTY("LastSourcePositionTime", nil) DECLARE_PROPERTY("SourceVelocity", nil) DECLARE_PROPERTY("SourceRadius", 4300) DECLARE_PROPERTY("ListenerOutOfRadius", false) DECLARE_PROPERTY("Id") DECLARE_PROPERTY("Url", "") DECLARE_PROPERTY("PlaybackSpeed", 1) DECLARE_PROPERTY("AdditivePitchModifier", 0) DECLARE_PROPERTY("SamplePosition", 0, ".position = %f") DECLARE_PROPERTY("PitchLFOAmount", nil, ".lfo_pitch_amount = %f") DECLARE_PROPERTY("PitchLFOTime", nil, ".lfo_pitch_time = %f") DECLARE_PROPERTY("VolumeLFOAmount", nil, ".lfo_volume_amount = %f") DECLARE_PROPERTY("VolumeLFOTime", nil, ".lfo_volume_time = %f") DECLARE_PROPERTY("FilterType", nil, ".filter_type = %i") DECLARE_PROPERTY("FilterFraction", 0, ".filter_fraction = %f", function(num) return math.Clamp(num, 0, 1) end) DECLARE_PROPERTY("Echo", false, ".useEcho(%s)") DECLARE_PROPERTY("EchoDelay", 1, ".setEchoDelay(Math.ceil(audio.sampleRate * %f))", function(num) return math.Clamp(num, 0, 5) end) DECLARE_PROPERTY("EchoFeedback", 0.75, ".echo_feedback = %f") -- State function META:IsReady() return self.Loaded end function META:GetLength() if not self.Loaded then return 0 end return self.SampleCount / tonumber(webaudio.sample_rate) end function META:IsValid() return self.invalid == nil end function META:Remove() webaudio.streams[self:GetId()] = nil self:Stop() run_javascript(string.format("DestroyStream(%i)", self:GetId())) self.invalid = true end -- Browser function META:Call(fmt, ...) local code = string.format("var id = %d; try { if (streams[id]) { streams[id]%s } } catch(e) { dprint('streams[' + id + '] ' + e.toString()) }", self:GetId(), string.format(fmt, ...)) run_javascript(code, self) end function META:HandleBrowserMessage(t, ...) if t == "call" then self:HandleCallBrowserMessage(...) elseif t == "fft" then self:HandleFFTBrowserMessage(...) elseif t == "stop" then self.Paused = true elseif t == "return" then self.ReturnedValues = {...} elseif t == "loaded" then self:HandleLoadedBrowserMessage(...) elseif t == "position" then self:HandlePositionBrowserMessage(...) end end function META:SetMaxLoopCount(maxLoopCount) self.MaxLoopCount = maxLoopCount self:Call(".max_loop = %i", maxLoopCount == true and -1 or maxLoopCount == false and 1 or tonumber(maxLoopCount) or 1) end function META:Pause() self.Paused = true self:Call(".play(false)") end function META:Resume() self.Paused = false self:UpdatePlaybackSpeed() self:UpdateVolume() self:Call(".play(true)") end function META:Play() self.Paused = false queue_javascript() self:UpdatePlaybackSpeed() self:UpdateVolume() self:Call(".play(true, 0)") execute_javascript() end function META:Stop() self.Paused = true self:Call(".play(false, 0)") end function META:Restart() self:SetSamplePosition(0) end function META:SetPosition(pos) self:SetSamplePosition((pos % 1) * self:GetSampleCount()) end function META:SetPlaybackRate(mult) if self.PlaybackSpeed == mult then return self end self.PlaybackSpeed = mult self:UpdatePlaybackSpeed() return self end function META:SetAdditivePitchModifier(additivePitchModifier) if self.AdditivePitchModifier == additivePitchModifier then return self end self.AdditivePitchModifier = additivePitchModifier self:UpdatePlaybackSpeed() return self end function META:UpdatePlaybackSpeed(add) local speed = self.PlaybackSpeed + self.AdditivePitchModifier if speed < 0 then self:Call(".reverse = true") speed = math.abs(speed) end if add then speed = speed + add end if speed ~= self.last_speed then self:Call(".speed = %f", speed) self.last_speed = speed end end function META:SetPanning(panning) if self.Panning == panning then return self end self.Panning = panning self:UpdateVolume() return self end function META:SetVolume(volumeFraction) if self.Volume == volumeFraction then return self end self.Volume = volumeFraction self:UpdateVolume() return self end function META:SetAdditiveVolumeModifier(additiveVolumeFraction) if self.AdditiveVolumeFraction == additiveVolumeFraction then return self end self.AdditiveVolumeFraction = additiveVolumeFraction self:UpdateVolume() return self end local function FindHeadPos(ent) if ent.findheadpos_last_mdl ~= ent:GetModel() then ent.findheadpos_head_bone = nil ent.findheadpos_head_attachment = nil ent.findheadpos_last_mdl = ent:GetModel() end if not ent.findheadpos_head_bone then for i = 0, ent:GetBoneCount() or 0 do local name = ent:GetBoneName(i):lower() if name:find("head", nil, true) then ent.findheadpos_head_bone = i break end end end if ent.findheadpos_head_bone then local m = ent:GetBoneMatrix(ent.findheadpos_head_bone) if m then local pos = m:GetTranslation() if pos ~= ent:GetPos() then return pos, m:GetAngles() end end else if not ent.findheadpos_attachment_eyes then ent.findheadpos_head_attachment = ent:GetAttachments().eyes or ent:GetAttachments().forward end if ent.findheadpos_head_attachment then local angpos = ent:GetAttachment(ent.findheadpos_head_attachment) return angpos.Pos, angpos.Ang end end return ent:EyePos(), ent:EyeAngles() end function META:UpdateSourcePosition() if not self.SourceEntity:IsValid() then self:OutOfRadius() return end self.SourcePosition = FindHeadPos(self.SourceEntity) end function META:UpdateVolume() queue_javascript() if self:Get3D() then self:UpdateVolume3d() else self:UpdateVolumeFlat() end execute_javascript() end function META:UpdateVolumeFlat() self:SetRightVolume((math.Clamp(1 + self.Panning, 0, 1)) + self.AdditiveVolumeFraction) self:SetLeftVolume((math.Clamp(1 - self.Panning, 0, 1)) + self.AdditiveVolumeFraction) end function META:UpdateVolumeBoth() if self.last_vol_both ~= self.Volume then self:Call(".vol_both= %f", self.Volume) self.last_vol_both = self.Volume end end function META:SetLeftVolume(vol) if self.last_left_volume ~= vol then self:Call(".vol_left= %f", vol) self.last_left_volume = vol end self:UpdateVolumeBoth() end function META:SetRightVolume(vol) if self.last_right_volume ~= vol then self:Call(".vol_right = %f", vol) self.last_right_volume = vol end self:UpdateVolumeBoth() end function META:UpdateVolume3d() if self.SourceEntity == pac.LocalPlayer and not pac.LocalPlayer:ShouldDrawLocalPlayer() then self:UpdateVolumeFlat() return end self:UpdateSourcePosition() local time = RealTime() self.SourcePosition = self.SourcePosition or Vector() self.LastSourcePosition = self.LastSourcePosition or self.SourcePosition self.LastSourcePositionTime = self.LastSourcePositionTime or (time - FrameTime()) self.SourceVelocity = (self.SourcePosition - self.LastSourcePosition) / (time - self.LastSourcePositionTime) self.LastSourcePosition = self.SourcePosition self.LastSourcePositionTime = time + 0.001 local relativeSourcePosition = self.SourcePosition - webaudio.eye_pos local distanceToSource = relativeSourcePosition:Length() if distanceToSource < self.SourceRadius then local pan = relativeSourcePosition:GetNormalized():Dot(webaudio.eye_ang:Right()) local volumeFraction = math.Clamp(1 - distanceToSource / self.SourceRadius, 0, 1) ^ 6 volumeFraction = volumeFraction * 0.5 self:SetRightVolume((math.Clamp(1 + pan, 0, 1) * volumeFraction) + self.AdditiveVolumeFraction) self:SetLeftVolume((math.Clamp(1 - pan, 0, 1) * volumeFraction) + self.AdditiveVolumeFraction) if self:GetDoppler() and webaudio.eye_velocity then local relativeSourceVelocity = self.SourceVelocity - webaudio.eye_velocity local relativeSourceSpeed = relativeSourcePosition:GetNormalized():Dot(-relativeSourceVelocity) * 0.0254 self:UpdatePlaybackSpeed(relativeSourceSpeed / webaudio.speed_of_sound) end self.ListenerOutOfRadius = false else self:OutOfRadius() end end function META:OutOfRadius() if not self.ListenerOutOfRadius then self:SetRightVolume(0) self:SetLeftVolume(0) self.ListenerOutOfRadius = true end end function META:SetSourceEntity(ent, dont_remove) self.SourceEntity = ent if not dont_remove and ent:IsValid() then ent:CallOnRemove("webaudio_remove_stream_" .. tostring(self), function() if self:IsValid() then self:Remove() end end) end end function META:Think() if self.Paused then return end self:UpdateVolume() end function META:__newindex(key, val) if key == "OnFFT" then if isfunction(val) then self:Call(".usefft(true)") else self:Call(".usefft(false)") end end rawset(self, key, val) end function META:__tostring() return string.format("stream[%p][%d][%s]", self, self:GetId(), self:GetUrl()) end -- Internal browser message handlers function META:HandleCallBrowserMessage(methodName, ...) if not self[methodName] then return end self[methodName](self, ...) end function META:HandleFFTBrowserMessage(serializeFFTData) local fftArray = CompileString(serializeFFTData, "stream_fft_data")() self.OnFFT(fftArray) end function META:HandleLoadedBrowserMessage(sampleCount) self.Loaded = true self.SampleCount = sampleCount queue_javascript() self:SetFilterType(0) self:SetMaxLoopCount(self:GetMaxLoopCount()) self:SetEcho(self:GetEcho()) self:SetEchoFeedback(self:GetEchoFeedback()) self:SetEchoDelay(self:GetEchoDelay()) execute_javascript() if self.OnLoad then self:OnLoad() end if self.js_queue then for _, code in ipairs(self.js_queue) do run_javascript(code) end self.js_queue = nil end end function META:HandlePositionBrowserMessage(samplePosition) self.SamplePosition = samplePosition end webaudio.stream_meta = META end webaudio.streams = webaudio.streams or {} webaudio.last_stream_id = 0 function webaudio.CreateStream(path) webaudio.Initialize() path = "../" .. path local self = setmetatable({}, webaudio.stream_meta) webaudio.last_stream_id = webaudio.last_stream_id + 1 self:SetId(webaudio.last_stream_id) self:SetUrl(path) webaudio.streams[self:GetId()] = self run_javascript(string.format("CreateStream(%q, %i)", self:GetUrl(), self:GetId())) return self end function webaudio.Panic(strong) for k,v in pairs(webaudio.streams) do v:Remove() end webaudio.last_stream_id = 0 end function webaudio.GetStream(streamId) return webaudio.streams[streamId] or NULL end function webaudio.StreamExists(streamId) return webaudio.streams[streamId] ~= nil end return webaudio