diff --git a/lua/autorun/wire_load.lua b/lua/autorun/wire_load.lua index 8e1f070460..76a1c6b99e 100644 --- a/lua/autorun/wire_load.lua +++ b/lua/autorun/wire_load.lua @@ -37,6 +37,7 @@ if SERVER then AddCSLuaFile("wire/flir.lua") AddCSLuaFile("wire/von.lua") AddCSLuaFile("wire/sh_modelplug.lua") + AddCSLuaFile("wire/soundlib.lua") -- client includes AddCSLuaFile("wire/client/cl_wirelib.lua") @@ -85,6 +86,7 @@ include("wire/timedpairs.lua") include("wire/default_data_decompressor.lua") include("wire/flir.lua") include("wire/von.lua") +include("wire/soundlib.lua") -- server includes if SERVER then diff --git a/lua/entities/gmod_wire_adv_microphone.lua b/lua/entities/gmod_wire_adv_microphone.lua new file mode 100644 index 0000000000..a43b5ee0a6 --- /dev/null +++ b/lua/entities/gmod_wire_adv_microphone.lua @@ -0,0 +1,218 @@ +AddCSLuaFile() + +local MIN_VOLUME = 0.02 + +ENT.Type = "anim" +ENT.Base = "base_wire_entity" +ENT.Author = "stpM64" +ENT.PrintName = "Wire Advanced Microphone" +ENT.Purpose = "Listens to sounds, soundscapes and player voices" +-- Named 'advanced' because 'gmod_wire_microphone' exists in Wire Extras +ENT.WireDebugName = "Advanced Microphone" + +-- Note: we listen both serverside and clientside, +-- because some sounds are played clientside only + +-- array(Entity(gmod_wire_adv_microphone)) +-- Array instead of lookup table because sounds are emitted more often than microphones switched on or off, +-- so iteration is more frequent than insertion/removal. +-- Microphone is live when it is active and at least one active speaker connected to it. +_WireLiveMicrophones = _WireLiveMicrophones or {} +local LiveMics = _WireLiveMicrophones + +function ENT:SetupDataTables() + self:NetworkVar("Bool", 0, "Active") + self:NetworkVarNotify("Active", self.OnActiveChanged) +end + +function ENT:Initialize() + if SERVER then + self:PhysicsInit( SOLID_VPHYSICS ) + self:SetMoveType( MOVETYPE_VPHYSICS ) + self:SetSolid( SOLID_VPHYSICS ) + + self.Inputs = WireLib.CreateInputs(self, { + "Active" + }) + + self._plyCache = WireLib.Sound.NewPlayerDistanceCache(self, WireLib.Sound.VOICE_MAXDIST_SQR) + end + + -- table(Entity(gmod_wire_adv_speaker), true) + self._activeSpeakers = {} + + -- Callback not called if 'Active' changes right after creation. + self:OnActiveChanged(nil, nil, self:GetActive()) +end + +if SERVER then + function ENT:Think() + if not self:GetLive() then return end + + self._plyCache:Think() + end +end + +local function PlayerCanHearPlayersVoice_Hook(listener, talker) + -- Note: any given speaker can only be connected to one microphone, + -- so this loops can be considered O(nMic), not O(nMic*nSpeaker) + for _, mic in ipairs(LiveMics) do + if not mic._plyCache.PlayersInRange[talker] then goto mic_next end + + for speaker in pairs(mic._activeSpeakers) do + if IsValid(speaker) and speaker._plyCache.PlayersInRange[listener] then + return true, false -- Can hear, not in 3D + end + end + ::mic_next:: + end +end + +local function Mic_SetLive(self, isLive) + if not IsValid(self) then + isLive = false + else + if self:GetLive() == isLive then return end + self:AddEFlags(EFL_FORCE_CHECK_TRANSMIT) + end + + if isLive then + if LiveMics[1] == nil then -- Adding first microphone to live list + hook.Add("PlayerCanHearPlayersVoice", "Wire.AdvMicrophone", PlayerCanHearPlayersVoice_Hook) + end + + if not table.HasValue(LiveMics, self) then + table.insert(LiveMics, self) + end + else + if LiveMics[1] ~= nil and LiveMics[2] == nil then -- Removing last microphone from live list + hook.Remove("PlayerCanHearPlayersVoice", "Wire.AdvMicrophone") + end + + table.RemoveByValue(LiveMics, self) + end +end + +ENT.SetLive = Mic_SetLive + +function ENT:UpdateTransmitState() + return self:GetLive() and TRANSMIT_ALWAYS or TRANSMIT_PVS +end + +function ENT:GetLive() + return self:GetActive() and not table.IsEmpty(self._activeSpeakers) +end + +function ENT:OnActiveChanged(_,_,active) + if self._activeSpeakers == nil then return end -- This happens on duplication restoration + if table.IsEmpty(self._activeSpeakers) then return end + self:SetLive(active) +end + +function ENT:TriggerInput( name, value ) + if name == "Active" then + self:SetActive(value ~= 0) + end +end + +function ENT:SpeakerActivated(speaker) + if not IsValid(speaker) then return end + + if self:GetActive() then + -- Must be updated before ._activeSpeakers are updated + self:SetLive(true) + end + self._activeSpeakers[speaker] = true +end + +function ENT:SpeakerDeactivated(speaker) + if self:GetActive() then + local live = true + do + local spk = self._activeSpeakers + + local k1 = next(spk) + if k1 == nil then -- No active speakers + live = false + else + local k2 = next(spk) + if k2 == nil and k1 == speaker then -- The only active speaker is 'speaker' + live = false + end + end + end + + -- Must be updated before ._activeSpeakers are updated + self:SetLive(live) + end + self._activeSpeakers[speaker] = nil +end + +function ENT:OnRemove() + timer.Simple(0, function() + if IsValid(self) then return end + + Mic_SetLive(self, false) + end) +end + +local CalculateDistanceGain = WireLib.Sound.CalculateDistanceGain + +hook.Add("EntityEmitSound", "Wire.AdvMicrophone", function(snd) + for _, mic in ipairs(LiveMics) do + if IsValid(mic) then + mic:HandleSound( + snd.SoundName, snd.Volume or 1, snd.Pitch or 100, snd.SoundLevel or 75, + snd.Entity, snd.Pos, snd.DSP or 0, + "EmitSound" + ) + end + end +end) + +hook.Add("Wire_SoundPlay", "Wire.AdvMicrophone", function(name, pos, level, pitch, volume) + for _, mic in ipairs(LiveMics) do + if IsValid(mic) then + mic:HandleSound( + name, volume, pitch, level, + nil --[[entity]], pos, 0 --[[DSP]], + "sound.Play" + ) + end + end +end) + +function ENT:HandleSound(sndname, volume, pitch, sndlevel, entity, pos, dsp, emittype) + -- Prevent feedback loops + if IsValid(entity) and entity:GetClass() == "gmod_wire_adv_speaker" then return end + if pos == nil and IsValid(entity) then pos = entity:GetPos() end + + if sndlevel ~= 0 and pos ~= nil then + -- Over-256 values are 'reserved for sounds using goldsrc compatibility attenuation' + -- I don't care about correct attenuation for HLSource entities, + -- but I don't want the system to break. + if sndlevel >= 256 then sndlevel = sndlevel - 256 end + + volume = volume * CalculateDistanceGain( + self:GetPos():Distance(pos), sndlevel) + end + + if volume < MIN_VOLUME then return end + if volume > 1 then volume = 1 end + + self:ReproduceSound(sndname, volume, pitch, dsp, emittype) +end + +function ENT:ReproduceSound(snd, vol, pitch, dsp, emittype) + for speaker in pairs(self._activeSpeakers) do + if IsValid(speaker) then + speaker:ReproduceSound(snd, vol, pitch, dsp, emittype) + end + end +end + +-- TODO: hook into sound.PlayFile +-- TODO: hook into sound.PlayURL + + +duplicator.RegisterEntityClass("gmod_wire_adv_microphone", WireLib.MakeWireEnt, "Data") \ No newline at end of file diff --git a/lua/entities/gmod_wire_adv_speaker.lua b/lua/entities/gmod_wire_adv_speaker.lua new file mode 100644 index 0000000000..c55c39e109 --- /dev/null +++ b/lua/entities/gmod_wire_adv_speaker.lua @@ -0,0 +1,110 @@ +AddCSLuaFile() + +ENT.Type = "anim" +ENT.Base = "base_wire_entity" +ENT.Author = "stpM64" +ENT.PrintName = "Wire Advanced Speaker" +ENT.Purpose = "Reproduces sounds, soundscapes and player voices listened by Advanced Microphone" +ENT.WireDebugName = "Advanced Speaker" + +-- TODO: stop currently played EmitSound sounds on deactivation + +function ENT:SetupDataTables() + self:NetworkVar("Bool", 0, "Active") + self:NetworkVarNotify("Active", self.OnActiveChanged) + self:NetworkVar("Entity", 0, "Microphone") + self:NetworkVarNotify("Microphone",self.OnMicrophoneChanged) +end + +if SERVER then + function ENT:Initialize() + self:PhysicsInit( SOLID_VPHYSICS ) + self:SetMoveType( MOVETYPE_VPHYSICS ) + self:SetSolid( SOLID_VPHYSICS ) + + self.Inputs = WireLib.CreateInputs(self, { + "Active", + "Microphone (Must be Wire Advanced Microphone to work) [ENTITY]" + }) + + self:OnMicrophoneChanged(nil, nil, self:GetMicrophone()) + + self._plyCache = WireLib.Sound.NewPlayerDistanceCache(self, WireLib.Sound.VOICE_MAXDIST_SQR) + end + + function ENT:Think() + if not self:GetActive() then return end + + local mic = self:GetMicrophone() + if not IsValid(mic) or not mic:GetActive() then return end + + self._plyCache:Think() + end +end + +function ENT:TriggerInput( name, value ) + if name == "Active" then + self:SetActive(value ~= 0) + elseif name == "Microphone" then + if not (IsValid(value) and value:GetClass() == "gmod_wire_adv_microphone") then + value = nil + end + + self:SetMicrophone(value) + end +end + +function ENT:OnActiveChanged(_, oldactive, active) + if oldactive == active then return end + + local mic = self:GetMicrophone() + if not IsValid(mic) then return end + + if active then + mic:SpeakerActivated(self) + else + mic:SpeakerDeactivated(self) + end +end + +function ENT:OnMicrophoneChanged(_, oldmic, newmic) + if self:GetActive() and oldmic ~= newmic then + if IsValid(oldmic) then + oldmic:SpeakerDeactivated(self) + end + + if IsValid(newmic) then + newmic:SpeakerActivated(self) + end + end +end + +function ENT:OnRemove() + local mic = self:GetMicrophone() + if not IsValid(mic) then return end + + timer.Simple(0, function() + if IsValid(self) or not IsValid(mic) then return end + mic:SpeakerDeactivated(self) + end) +end + +function ENT:ReproduceSound(snd, vol, pitch, dsp, emittype) + if not self:GetActive() then return end + + if WireLib.Sound.IsLooped(WireLib.Sound.StripPrefix(snd)) then + return + end + + + local soundlevel = 75 + if emittype == "EmitSound" then + self:EmitSound(snd, soundlevel, pitch, vol, nil, nil, dsp) + elseif emittype == "sound.Play" then + sound.Play_NoWireHook(snd, self:GetPos(), soundlevel, pitch, vol) + else + ErrorNoHalt("Invalid emittype: ", emittype,"\n --sound ",snd) + end +end + +duplicator.RegisterEntityClass("gmod_wire_adv_speaker", WireLib.MakeWireEnt, "Data") \ No newline at end of file diff --git a/lua/wire/soundlib.lua b/lua/wire/soundlib.lua new file mode 100644 index 0000000000..a7f78c9e30 --- /dev/null +++ b/lua/wire/soundlib.lua @@ -0,0 +1,160 @@ +WireLib.Sound = WireLib.Sound or {} +local lib = WireLib.Sound + +local LoopedCache = {} + +local function Riff_ReadChunkHeader(fil) + local id = fil:Read(4) + local content_len = fil:ReadULong() + if content_len == nil then return nil, nil end + + content_len = content_len + bit.band(content_len, 1) + + return id, content_len +end + +local function WavIsLooped_Impl(path) + local fil = file.Open(path, "r", "GAME") + if fil == nil then + return false + end + + local id, _ = Riff_ReadChunkHeader(fil) + if id ~= "RIFF" then return false end -- Invalid header + if fil:Read(4) ~= "WAVE" then return false end -- Invalid second header + + local resultid + + while true do + local id, len = Riff_ReadChunkHeader(fil) + if id == "cue " or id == "smpl" then resultid = id break end + if id == nil then + ErrorNoHaltWithStack("WavIsLooped_Impl: Can't analyze file ", path) + return false + end -- Some error + + local p1 = fil:Tell() + local pnext = p1 + len + + if pnext == fil:Size() then return false end -- End of file + fil:Seek(pnext) + end + + if resultid == "cue " then + local cue_count = fil:ReadULong() + fil:Close() + + return cue_count ~= 0 + elseif resultid == "smpl" then + fil:Skip(7*4) + local sampler_count = fil:ReadULong() + fil:Close() + + return sampler_count ~= 0 + end + + +end + +local function WavIsLooped(path) + if LoopedCache[path] ~= nil then return LoopedCache[path] end + + local looped = WavIsLooped_Impl(path) + LoopedCache[path] = looped + return looped +end + +function lib.IsLooped(path) + path = "sound/"..path + local ext = string.GetExtensionFromFilename(path) + if ext == "wav" then + return WavIsLooped(path) + else + return false -- MP3s are not loopable + end +end + +local PREFIX_CHARS = {"*","#","@",">","<","^",")","(","}","$","!","?"} +local PREFIX_REGEX +do + local chars_escaped = {} + for i, ch in ipairs(PREFIX_CHARS) do + chars_escaped[i] = string.PatternSafe(ch) + end + + PREFIX_REGEX = "^["..table.concat(chars_escaped).."]+" +end + + + +function lib.StripPrefix(path) + return string.gsub(path, PREFIX_REGEX, "") +end + +sound.Play_NoWireHook = sound.Play_NoWireHook or sound.Play +function sound.Play(snd, pos, level, pitch, volume, ...) + hook.Run("Wire_SoundPlay", snd, pos, level or 75, pitch or 100, volume or 1, ...) + + sound.Play_NoWireHook(snd, pos, level, pitch, volume, ...) +end + + +local CVAR_snd_refdb = GetConVar("snd_refdb") +local CVAR_snd_refdist = GetConVar("snd_refdist") +local MAX_DIST_GAIN = 1000 + +function lib.CalculateDistanceGain(dist, sndlevel) + -- See SNDLVL_TO_DIST_MULT in engine/audio/private/snd_dma.cpp + -- See SND_GetGainFromMult in engine/sound_shared.cpp + + local finalsndlevel = CVAR_snd_refdb:GetFloat() - sndlevel + local distMul = math.pow(10, finalsndlevel / 20) / CVAR_snd_refdist:GetFloat() + + local gain = 1/(distMul * dist) + + return math.min(gain, MAX_DIST_GAIN) -- No infinities +end + +-- Maximum distance from player to adv_microphone and from adv_speaker to player, in which voice can be heard +lib.VOICE_MAXDIST_SQR = 250 * 250 + +lib._DCACHE = lib._DCACHE or {} +local DCACHE = lib._DCACHE +DCACHE.__index = DCACHE + +--[[ + struct PlayerDistanceCache { + fn :Think() + + readonly .PlayersInRange: table(Player, true) + } + + fn WireLib.Sound.NewPlayerDistanceCache(ent: Entity, max_dist_sqr: number) -> PlayerDistanceCache +]] + +function lib.NewPlayerDistanceCache(ent, max_dist_sqr) + local obj = setmetatable({ + _ent = ent, + _maxDistSqr = max_dist_sqr, + PlayersInRange = {} + }, DCACHE) + + return obj +end + +function DCACHE:Think() + local ent = self._ent + if not IsValid(ent) then return end + + local plys = {} + self.PlayersInRange = plys + + local entPos = ent:GetPos() + local maxDistSqr = self._maxDistSqr + + for _, ply in ipairs(player.GetHumans()) do + if ply:GetPos():DistToSqr(entPos) < maxDistSqr then + plys[ply] = true + end + end +end \ No newline at end of file diff --git a/lua/wire/stools/adv_microphone.lua b/lua/wire/stools/adv_microphone.lua new file mode 100644 index 0000000000..ea49dc3a2c --- /dev/null +++ b/lua/wire/stools/adv_microphone.lua @@ -0,0 +1,22 @@ +WireToolSetup.setCategory( "Other/Sound" ) +WireToolSetup.open( "adv_microphone", "Adv. Microphone", "gmod_wire_adv_microphone", nil, "Adv. Microphones" ) + +if CLIENT then + language.Add("tool.wire_adv_microphone.name", "Advanced Microphone") + language.Add("tool.wire_adv_microphone.desc", "Places Advanced Microphones") + language.Add("tool.wire_adv_microphone.0", "Create or update microphone") + + -- TODO: icon? +end + +WireToolSetup.BaseLang() +WireToolSetup.SetupMax( 20 ) + +TOOL.ClientConVar = { + model = "models/jaanus/wiretool/wiretool_siren.mdl", +} + + +function TOOL.BuildCPanel(panel) + ModelPlug_AddToCPanel(panel, "Misc_Tools", "wire_adv_microphone", true) +end \ No newline at end of file diff --git a/lua/wire/stools/adv_speaker.lua b/lua/wire/stools/adv_speaker.lua new file mode 100644 index 0000000000..6f7dda3e5a --- /dev/null +++ b/lua/wire/stools/adv_speaker.lua @@ -0,0 +1,25 @@ +WireToolSetup.setCategory( "Other/Sound" ) +WireToolSetup.open( "adv_speaker", "Adv. Speaker", "gmod_wire_adv_speaker", nil, "Adv. Speakers" ) + +if CLIENT then + language.Add("tool.wire_adv_speaker.name", "Advanced Speaker") + language.Add("tool.wire_adv_speaker.desc", "Places Advanced Speakers") + language.Add("tool.wire_adv_speaker.0", "Create or update speaker") + + WireToolSetup.setToolMenuIcon( "icon16/sound.png" ) +end + +WireToolSetup.BaseLang() +WireToolSetup.SetupMax( 20 ) + +if SERVER then + ModelPlug_Register("speaker") +end + +TOOL.ClientConVar = { + model = "models/cheeze/wires/speaker.mdl", +} + +function TOOL.BuildCPanel(panel) + ModelPlug_AddToCPanel(panel, "speaker", "wire_adv_speaker", true) +end \ No newline at end of file