---@ext
--[[
  Self-contained wrapper for Mumble client plugin. Include-and-run, and call functions in returned objects in time
  to render UI and update the state.
]]

-- Definitions

local HAS_FLAG = const(function(item, flag) return bit.band(item.flags, flag) ~= 0 end)

local FLAG_DEFAULT = const(1)
local FLAG_ACTIVE = const(2)
local FLAG_SELECTED = const(4)

local FLAG_TALKING = const(1)
local FLAG_MUTED = const(2)
local FLAG_SELF_MUTED = const(4)
local FLAG_SUPRESSED = const(8)
local FLAG_DEAF = const(16)
local FLAG_SELF_DEAF = const(32)
local FLAG_STREAM = const(64)
local FLAG_IMMEDIATE_TALKING = const(128)
local FLAG_POSITIONLESS = const(256)
local FLAG_MUTED_ANY = const(2 + 4 + 8)

local DEF_DEVICE_NAME_SIZE = const(256)
local DEF_DEVICE_ICON_SIZE = const(128)
local DEF_COMMAND_SIZE = const(256)
local DEF_MAX_DEVICE_COUNT = const(64)
local DEF_MAX_COMMAND_COUNT = const(64)
local DEF_MAX_CONNECTED_COUNT = const(256)
local DEF_CHANNELS_DATA_SIZE = const(32768)
local DEF_EXPECTED_MARK_VALUE = const(12345679)

local SETTINGS_COLUMN_WIDTH = const(90)

---@alias MumbleConfig {configPrefix: string?, host: string, port: integer, password: string?, channel: string?, context: string?, use3D: boolean?, useDirectPTT: boolean?, maxDistance: number?, muteDistance: number?, usernamePrefix: string?}
---@alias MumbleWrapper {update: fun(dt: number), main: fun(), settings: fun(), fullscreen: fun()}

ffi.cdef(const('\
  typedef struct { const char name['..DEF_DEVICE_NAME_SIZE..']; const char icon['..DEF_DEVICE_ICON_SIZE..']; uint32_t flags; } mumble_DeviceInfo;\
  typedef struct { uint8_t sessionID; uint8_t peakVolume; uint16_t flags; uint32_t channelID; } mumble_ConnectedState;\
  typedef struct { char value['..DEF_COMMAND_SIZE..']; } mumble_CommandInfo;'))

---@alias MumbleMMF.Device {name: string, icon: string, flags: integer}
---@alias MumbleMMF.User {sessionID: integer, peakVolume: integer, flags: integer, channelID: integer}
---@alias MumbleMMF.Channel {id: integer, name: string, description: string, canEnter: boolean, isEnterRestricted: boolean, maxUsers: integer, children: MumbleMMF.Channel[], parent: MumbleMMF.Channel?, currentUsers: number, directUsers: number}

---@class MumbleMMF
---@field numInputDevices integer
---@field numOutputDevices integer
---@field inputDevices MumbleMMF.Device[]
---@field outputDevices MumbleMMF.Device[]
---@field bitrate integer
---@field streamConnectPointSize integer
---@field commands {value: string}[]
---@field numCommands integer
---@field frameIndex integer
---@field listenerPos vec3
---@field listenerDir vec3
---@field listenerUp vec3
---@field audioSourcePos vec3
---@field pushToTalk boolean
---@field requireMicPeak boolean
---@field serverLoopback boolean
---@field positionlessInput boolean
---@field numCurrentlyConnected integer
---@field currentlyConnected MumbleMMF.User[]
---@field channelsPhase integer
---@field channelsData any
---@field micPeak number
---@field mark integer
---@return MumbleMMF
local function MumbleMMF(endpoint)
  local ret = ac.writeMemoryMappedFile(endpoint, const('\
    int bitrate;\
    int streamConnectPointSize;\
    int numInputDevices;\
    int numOutputDevices;\
    mumble_DeviceInfo inputDevices['..DEF_MAX_DEVICE_COUNT..'];\
    mumble_DeviceInfo outputDevices['..DEF_MAX_DEVICE_COUNT..'];\
    mumble_CommandInfo commands['..DEF_MAX_COMMAND_COUNT..'];\
    int numCommands;\
    int frameIndex;\
    vec3 listenerPos;\
    vec3 listenerDir;\
    vec3 listenerUp;\
    vec3 audioSourcePos;\
    bool pushToTalk;\
    bool requireMicPeak;\
    bool serverLoopback;\
    bool positionlessInput;\
    int numCurrentlyConnected;\
    mumble_ConnectedState currentlyConnected['..DEF_MAX_CONNECTED_COUNT..'];\
    int channelsPhase;\
    char channelsData['..DEF_CHANNELS_DATA_SIZE..'];\
    float micPeak;\
    int mark;'))
  ret.mark = DEF_EXPECTED_MARK_VALUE
  ac.onRelease(function () ret.mark = DEF_EXPECTED_MARK_VALUE + 1 end)
  return ret
end

-- Cache of key structs

local sim = ac.getSim()
local uiState = ac.getUI()
local ownCar = ac.getCar(0)
if not ownCar then error('Player car is missing', 2) end

-- Warning about new PTT mode
local function showAdaptivePTTNote()
  ui.modalPopup('PTT-aware voice chat', 'Server uses adaptive PTT mode.\n\nHolding PTT button will broadcast your voice on radio for all. If button is released (and voice input mode is set to something other that PTT), only people nearby will hear you.', 'Got it', 'Don’t show again', ui.Icons.Confirm, ui.Icons.Save, function (okPressed)
    if not okPressed then
      ac.storage.pptMsgShown = true
    else
      ac.storage.pptMsgShown = nil
    end
  end)
end

-- Utility functions

---@type fun(maxDelaySeconds: number): fun(value: number, delay: number): number
local function delayed(maxDelaySeconds)
  local t, j, steps = {}, 1, 1 + math.ceil(60 * maxDelaySeconds)
  for i = 1, steps do t[i] = 0 end
  return function (current, delaySeconds)
    j = j + 1
    if j == steps + 1 then j = 1 end
    local delay = math.floor(delaySeconds / uiState.dt)
    t[j] = current
    local bak = j - math.clamp(delay, 0, steps - 1)
    if bak < 1 then bak = bak + steps end
    return t[bak]
  end
end

---@type fun(v: boolean|number|table|nil): string
local function encodeValue(v)
  local t = type(v)
  if t == 'boolean' then return v and '1' or '0' end
  if t == 'table' then return table.concat(table.map(v, encodeValue), '\t') end
  return v and tostring(v) or ''
end

---@type fun(data: table): string
local function encodeConfig(data)
  return table.concat(table.map(data, function (v, k) return k..'\t'..encodeValue(v) end), '\n') 
end

---@type fun(peak: number): ui.ExtraCanvas
local getTalkingIcon = (function ()
  local cache = table.range(32, function () return false end)
  return function (value)
    local key = 1 + math.floor(value * 31.999)
    local ret = cache[key]
    if not ret then
      ret = ui.ExtraCanvas(64, 3)
      render.backupRenderTarget()
      ret:updateWithShader({
        textures = { txIcon = 'res/speaker.png' },
        values = { gPos = math.saturateN(((key - 1) / 32) * 1.3 - 0.1) },
        shader = 'res/speaker.fx',
        cacheKey = 0
      })
      render.restoreRenderTarget()
      cache[key] = ret
    end
    return ret
  end
end)()

local exitCodes = {
  [0] = { message = 'Completely unexpected error', restartDelay = math.huge }, -- this one should never really happen
  [12] = { message = 'Internal error', restartDelay = 10 },
  [13] = { message = 'Versions mismatch', restartDelay = math.huge },
  [14] = { message = 'Audio device failure', restartDelay = 10 },
  [15] = { message = 'Failed to find remote address', restartDelay = 3 },
  [16] = { message = 'Failed to connect', restartDelay = 3 },
  [17] = { message = 'Disconnected', restartDelay = 3 },
  [18] = { message = 'Waiting for a slot to clear', restartDelay = 3 },
  [19] = { message = 'Invalid password', restartDelay = math.huge },
  [20] = { message = 'Server refused connection', restartDelay = math.huge },
}

local pushToTalkButton = ac.ControlButton('__EXT_PUSHTOTALK')
local extraAudios = {} ---@type table<string, ac.AudioEvent>

local function playAudio(file)
  if extraAudios[file] then
    if extraAudios[file]:isValid() then --[[ still playing ]] return end
    extraAudios[file]:dispose()
  end
  print('Playing audio: %s' % file)
  extraAudios[file] = ac.AudioEvent.fromFile({filename = 'res/%s.mp3' % file, use3D = false, loop = false}, false):resume()
end

-- Actual state-storing implementation

---Create a new Mumble wrapper.
---@param mumbleParams MumbleConfig
---@return MumbleWrapper
return function(mumbleParams)

  ---@type MumbleConfig
  mumbleParams = table.chain({ host = 'localhost', port = 64738, channel = 'Root', use3D = true, maxDistance = 50, muteDistance = math.huge }, mumbleParams)

  if mumbleParams.useDirectPTT and not mumbleParams.use3D then
    mumbleParams.useDirectPTT = false
  end

  if mumbleParams.useDirectPTT and not ac.storage.pptMsgShown then
    setTimeout(showAdaptivePTTNote)
  end

  -- Wrapper config

  local config = ac.storage({
    inputDevice = '',
    inputVolume = 1,
    inputMode = 'pushToTalk', ---@type 'alwaysSend'|'pushToTalk'|'amplitude'|'voiceActivity'
    inputMode_holdSeconds = 0.5,
    inputMode_amplitude_minValue = 0.02,
    inputMode_voiceActivity_start = 85,
    inputMode_voiceActivity_continue = 65,
    inputBitrate = 24000,
    inputDenoise_speex = false,
    inputDenoise_speexSuppress = -25,
    inputDenoise_rnn = false,
    inputEchoCancellation = false,
    inputEchoCancellation_suppress = -45,
    inputEchoCancellation_suppressActive = -45,
    inputAutoGainControl = false,
    inputAutoGainControl_level = 24000,
    
    outputDevice = '',
    outputVolume = 1,
    outputFMOD = true,
    outputDoppler = true,
    outputReverb = true,
    audioFade = 0.5,
    audioFade_others = true,
    audioFade_own = true,

    systemForceTCP = false,
    systemSetQOS = false,

    perUser = '{}',
    settingsShown = false,
  }, mumbleParams.configPrefix)

  local function needsSpeexDSP()
    return config.inputMode == 'voiceActivity' or config.inputDenoise_speex or config.inputEchoCancellation --or config.inputAutoGainControl_level
  end

  -- List of commands to send to “backend”

  local commandsList, commandsCount = {}, 0
  local function addCommand(key, ...)
    local command = key
		for i = 1, select('#', ...) do
      command = command..'\t'..encodeValue(select(i, ...))
		end
    commandsCount = commandsCount + 1
    commandsList[commandsCount] = command
  end

  -- Notify about errors reported when trying to do things

  local attemptedOperation = nil ---@type {time: number, operation: string}?
  local function makeAnAttempt(thing)
    attemptedOperation = {time = os.preciseClock(), operation = thing}
  end

  -- Connection point and “backend” process

  local mmfKey = 'AcTools.CSP.Mumble.v0__'..tostring(math.randomKey())
  local mmf = MumbleMMF(mmfKey)

  local logItems = {} ---@type string[]
  local processError = nil ---@type {message: string, timeOfAnotherAttempt: number}?
  local hideErrorMessage = false
  local ownSessionID = sim.isOnlineRace and ownCar.sessionID or 0
  local function startProcess()
    processError = nil
    table.clear(logItems)
    logItems[1] = 'i: Trying to establish Mumble connection…\n'
    table.clear(commandsList)
    commandsCount = 0
    ac.setWindowNotificationCounter('main', 0)
    ac.warn('config.inputDevice', config.inputDevice)
    os.runConsoleProcess({
      filename = ac.getFolder(ac.FolderID.ExtInternal)..'/plugins/AcTools.MumbleClient.exe',
      stdin = encodeConfig({
        ['system.connectPoint'] = mmfKey,
        ['system.streamConnectPointsPrefix'] = config.outputFMOD and mmfKey..'.' or '',
        ['system.forceTCP'] = config.systemForceTCP,
        ['system.setQOS'] = config.systemSetQOS,
        ['server.usernamePrefix'] = mumbleParams.usernamePrefix,
        ['data.sendPosition'] = mumbleParams.use3D or (mumbleParams.muteDistance or math.huge) < 1e9 or mumbleParams.useDirectPTT,
        ['audio.inputBitrate'] = config.inputBitrate,
        ['audio.inputDevice'] = config.inputDevice,
        ['audio.inputDevice.volume'] = config.inputVolume,
        ['audio.outputDevice'] = config.outputDevice ~= '' and config.outputDevice or ac.getAudioOutputDevice(),
        ['audio.outputDevice.volume'] = config.outputVolume,
        ['server.host'] = mumbleParams.host,
        ['server.port'] = mumbleParams.port,
        ['server.password'] = mumbleParams.password,
        ['server.userID'] = ownSessionID,
        ['server.userAgent'] = 'AcTools.MumbleClient/'..tostring(ac.getPatchVersionCode()),
        ['user.channel'] = mumbleParams.channel,
        ['user.pluginContext'] = mumbleParams.context and ac.encodeBase64('Assetto Corsa'..string.char(0)..mumbleParams.context, false),
        ['user.pluginIdentity'] = mumbleParams.context and ownSessionID,
  
        ['audio.positional.bloom'] = 0,
        ['audio.positional.muteDistance'] = math.max(3, mumbleParams.muteDistance) or math.huge,
        ['audio.positional.maxDistance'] = math.max(3, mumbleParams.maxDistance),
        ['audio.positional.maxDistanceVolume'] = 0,
        ['audio.positional.minDistance'] = 1,
  
        ['audio.inputMode'] = config.inputMode,
        ['audio.inputMode.holdSeconds'] = config.inputMode_holdSeconds,
        ['audio.inputMode.amplitude.minValue'] = config.inputMode_amplitude_minValue,
        ['filter.speexDSP'] = needsSpeexDSP(),
        ['filter.speexDSP.denoise'] = config.inputDenoise_speex,
        ['filter.speexDSP.denoise.suppress'] = config.inputDenoise_speexSuppress,
        ['filter.speexDSP.echo'] = config.inputEchoCancellation,
        ['filter.speexDSP.echo.suppress'] = config.inputEchoCancellation_suppress,
        ['filter.speexDSP.echo.suppressActive'] = config.inputEchoCancellation_suppressActive,
        ['filter.speexDSP.autoGainControl'] = config.inputAutoGainControl,
        ['filter.speexDSP.autoGainControl.level'] = config.inputAutoGainControl_level,
        ['filter.speexDSP.voiceActivityDetector'] = config.inputMode == 'voiceActivity',
        ['filter.speexDSP.voiceActivityDetector.start'] = config.inputMode_voiceActivity_start,
        ['filter.speexDSP.voiceActivityDetector.continue'] = config.inputMode_voiceActivity_continue,
        ['filter.rnnNoise'] = config.inputDenoise_rnn,
        ['audio.outputDesiredLatency'] = 100,
        ['audio.inputBufferMilliseconds'] = 100,
      }),
      dataCallback = function (err, data)
        -- if not sim.isOnlineRace then
        --   ac.log(data:trim())
        -- end
        if string.byte(data, 1) == const(string.byte('!', 1)) and string.byte(data, 2) == const(string.byte(':', 1))
            and attemptedOperation and os.preciseClock() - attemptedOperation.time < 2 then
          ui.toast(ui.Icons.Warning, 'Failed to '..attemptedOperation.operation..': '..data:sub(4))
          attemptedOperation = nil
        end
        if #logItems > 60 then
          table.remove(logItems, 1)
        end
        logItems[#logItems + 1] = data
      end,
      terminateWithScript = true,
      timeout = 0,
    }, function (err, data)
      if err then
        ac.error('Failed to launch Mumble client', err)
      else
        local errorData = exitCodes[data.exitCode]
        if not errorData then
          ac.error('Mumble client shut down', data.exitCode)
          processError = {message = 'Mumble client shut down: '..tostring(data.exitCode)}
        elseif errorData.restartDelay == math.huge then
          processError = {message = errorData.message}
        else
          processError = {message = errorData.message, timeOfAnotherAttempt = errorData.restartDelay + os.preciseClock()}
          setTimeout(startProcess, errorData.restartDelay)
          errorData.restartDelay = errorData.restartDelay + 5
        end
        if data.exitCode ~= 0 then
          ac.setWindowNotificationCounter('main', -1)
        end
      end
    end)
  end
  startProcess()

  -- Commands for online scripts

  ---@param data {channel: string?, mute: boolean?}
  local function commandCallback(data)
    if type(data) ~= 'table' then ac.warn('invalid command', data) return end
    if type(data.channel) == 'string' then addCommand('user.channel', data.channel) end
    if type(data.mute) == 'boolean' then addCommand('user.selfMute', data.mute) end
  end
  -- ac.broadcastSharedEvent('app.csp.mumble', {channel = 'ch2'})
  -- ac.broadcastSharedEvent('app.csp.mumble', {mute = true})

  -- Car data holders

  local playerIcons = ui.UserIconsLayer(100)
  local headTransform = mat4x4()

  ---@alias PerUserCfg {muted: boolean, volume: number}
  ---@type PerUserCfg[]
  local perUser = stringify.tryParse(config.perUser) or {}
  local perUserUpdating = false

  ---@param applied PerUserCfg
  ---@param target PerUserCfg
  ---@param sessionID integer?
  ---@return boolean
  local function syncPerUser(applied, target, sessionID)
    if (target.muted ~= nil and applied.muted ~= target.muted) or (target.volume ~= nil and applied.volume ~= target.volume) then
      table.assign(applied, target)
      if sessionID then addCommand('action.configureUser', sessionID, applied.muted and 0 or applied.volume) end
      return true
    end
    return false
  end

  ---@param cfg PerUserCfg
  local function isPerUserDefault(cfg)
    return (cfg.volume == nil or cfg.volume == 1) and (cfg.muted == nil or cfg.muted == false)
  end

  ---@class CarData
  local CarData = class('CarData')

  ---@param car ac.StateCar
  ---@param sessionID integer
  function CarData:initialize(car, sessionID)
    self.car = car
    self.sessionID = sessionID
    self.lastConnectedTime = -1
    self.talkingSmoothness = ui.SmoothInterpolation(0, 1)
    self.talkingAnimation = 0
    self.talkingIcon = nil ---@type ui.ExtraCanvas?
    self.delayedVolume = delayed(1)
    self.peakVolume = -1
    self.drawnVolume = -1
    self.closeEnough = true
    self.player = nil ---@type ac.AudioEvent?
    self.driverName = nil ---@type string?
    self.driverConfig = {muted = false, volume = 1}
    self.appliedConfig = {muted = false, volume = 1}
    self.driverTags = nil ---@type ac.DriverTags?
    self.peakSmoothed = 0
    self.flags = 0

    if car.isConnected then
      self:onConnected()
    else
      self:onDisconnected()
    end
  end

  function CarData:onConnected()
    self.driverName = ac.getDriverName(self.car.index)
    self.driverTags = ac.DriverTags(self.driverName)
    self.driverConfig = perUser[self.driverName] or {muted = false, volume = 1}
    if self.driverConfig.muted ~= self.driverTags.muted then
      self.driverConfig.muted = self.driverTags.muted
      self:saveDriverConfig()
    end
    syncPerUser(self.appliedConfig, self.driverConfig, self.sessionID)
  end

  function CarData:onDisconnected()
    self.driverTags = nil
    if not self.appliedConfig.muted then
      self.appliedConfig.muted = true
      addCommand('action.configureUser', self.sessionID, 0)
    end
  end

  function CarData:saveDriverConfig()
    if isPerUserDefault(self.driverConfig) then
      perUser[self.driverName] = nil
    else
      perUser[self.driverName] = self.driverConfig
    end
    if not perUserUpdating then
      perUserUpdating = true
      setTimeout(function ()
        config.perUser = stringify(perUser, true)
        perUserUpdating = false
      end)
    end    
  end

  ---@param params PerUserCfg
  function CarData:configure(params)
    if params.muted ~= nil and self.driverTags then
      self.driverTags.muted = params.muted
    end
    if self.car.isConnected then
      if syncPerUser(self.appliedConfig, params, self.sessionID) then
        table.assign(self.driverConfig, params)
        self:saveDriverConfig()
      end
    end
  end

  function CarData:restartFMODStream()
    if self.player then
      self.player:dispose()
      self.player = nil
    end
  end

  function CarData:isMumbleConnected()
    return os.preciseClock() < self.lastConnectedTime + 0.5
  end

  local mumbleTalkingColor = rgbm(0, 1, 1, 1)
  function CarData:getMumbleColor()
    if self.talkingAnimation > 0.01 then
      return mumbleTalkingColor
    elseif self:isMumbleConnected() then
      return rgbm.colors.lime
    else
      return rgbm.colors.gray
    end
  end

  ---@param user MumbleMMF.User
  ---@param dt number
  function CarData:update(user, volumeBoost, dt)
    -- In case driver name changes without reconnection:
    if ac.getDriverName(self.car.index) ~= self.driverName then
      self:onDisconnected()
      self:onConnected()
    end

    self.lastConnectedTime = os.preciseClock()
    self.flags = user.flags

    if self.driverTags and self.driverTags.muted ~= self.driverConfig.muted then
      self:configure({muted = self.driverTags.muted})
    end

    local flagTalking = HAS_FLAG(self, FLAG_TALKING) and self.car.isConnected
    if mumbleParams.useDirectPTT and self.car.index ~= 0 then
      if self.car.isConnected then
        if not self.pptIntro then
          self.pptIntro = true
          playAudio('connected')
        end
      elseif self.pptIntro then
        self.pptIntro = false
        playAudio('disconnected')
      end
    end
    if not flagTalking and self.talkingAnimation < 0.001 and self.peakSmoothed < 0.02 then
      if self.pttBeep and self.car.index ~= 0 then 
        self.pttBeep = false
        playAudio('mic_off')
      end
      if self.player then self.player.volume = 0 end
      return false
    end

    self.peakVolume = self.delayedVolume(tonumber(user.peakVolume) / 255, 0.1)
    self.talkingAnimation = self.talkingSmoothness(flagTalking and 1 or 0)
    self.talkingIcon = getTalkingIcon(self.peakVolume)
    playerIcons(self.car.index, flagTalking and self.talkingIcon or nil)

    if math.abs(self.peakVolume - self.peakSmoothed) > 0.01 then
      self.peakSmoothed = math.applyLag(self.peakSmoothed, self.peakVolume, 0.85, dt)
      ac.setDriverMouthOpened(self.car.index, math.lerpInvSat(self.peakSmoothed, 0.1, 0.6))
    end

    local flagHasStream = HAS_FLAG(self, FLAG_STREAM)
    if flagHasStream then
      if not self.player then
        self.player = self:_createStream()
      end
      self.player.volume = volumeBoost * config.outputVolume * math.max(0, 1 - self.car.distanceToCamera / mumbleParams.muteDistance)
      if mumbleParams.use3D then
        local velocity
        if mumbleParams.useDirectPTT and HAS_FLAG(self, FLAG_POSITIONLESS) then
          if not self.pttBeep and self.car.index ~= 0 then
            self.pttBeep = true
            playAudio('mic_on')
          end
          headTransform.position = sim.cameraPosition
          headTransform.look = sim.cameraLook
          headTransform.look = sim.cameraUp
          velocity = sim.closelyFocusedCar ~= -1 and ac.getCar(sim.closelyFocusedCar).velocity or vec3()
        else
          ac.getDriverHeadTransformTo(headTransform, self.car.index)
          velocity = self.car.velocity
        end
        if mumbleParams.useDirectPTT then
          self.player
            :setDSPParameter(0, 0, HAS_FLAG(self, FLAG_POSITIONLESS) and 1200 or 22e3)
            :setDSPParameter(1, 0, HAS_FLAG(self, FLAG_POSITIONLESS) and 1500 or 0)
        end
        self.player:setPosition(headTransform.position, headTransform.look:normalize(), headTransform.up, velocity)
      end
    elseif self.player then
      self.player:dispose()
      self.player = nil
    end

    return self.talkingAnimation > 0.01
  end

  function CarData:_createStream()
    local player = ac.AudioEvent.fromFile({
      stream = { name = mmfKey..'.'..self.sessionID, size = mmf.streamConnectPointSize },
      use3D = mumbleParams.use3D,
      useOcclusion = true,
      maxDistance = math.max(3, mumbleParams.maxDistance),
      minDistance = 1,
      insideConeAngle = 120,
      outsideConeAngle = 240,
      outsideVolume = 0.6,
      dopplerEffect = (mumbleParams.use3D and config.outputDoppler) and 1 or 0,
      dsp = mumbleParams.useDirectPTT and { ac.AudioDSP.LowPass, ac.AudioDSP.HighPass } or nil,
    }, mumbleParams.use3D and config.outputReverb or false)
    player.volume = config.outputVolume
    player.cameraExteriorMultiplier = 1
    player.cameraInteriorMultiplier = 1
    player.cameraTrackMultiplier = 1
    return player
  end

  ---@type CarData[]
  local carCache = table.range(sim.carsCount - 1, 0, function (i)
    local car = ac.getCar(i)
    if car then
      local key = sim.isOnlineRace and car.sessionID or car.index
      return CarData(car, key), key
    end
  end)

  ---@return CarData
  local function getExtrasFor(sessionID)
    return carCache[sessionID]
  end

  local function updateFMODStreamsVolume()
    for i = 0, #carCache do
      local player = carCache[i].player
      if player then
        player.volume = config.outputVolume
      end
    end
  end

  local function restartFMODStreams()
    for i = 0, #carCache do
      carCache[i]:restartFMODStream()
    end
  end

  ac.onClientConnected(function (_, connectedSessionID) getExtrasFor(connectedSessionID):onConnected() end)
  ac.onClientDisconnected(function (_, connectedSessionID) getExtrasFor(connectedSessionID):onDisconnected() end)

  -- Syncing state with “backend”

  local talkingList, talkingCount = {}, 0 ---@type CarData[], integer
  local lastChannelsPhase, channelsData, channelsMap = -1, {}, {} ---@type integer, MumbleMMF.Channel, table<integer, MumbleMMF.Channel>
  local volumeMultiplier = 1
  local actualVolumeMultiplier = 1
  local lastMicRequireFrame = 0
  local lastServerLoopbackFrame = 0
  local ownMutedSmoothness = ui.SmoothInterpolation(0, 1)
  local ownMutedAnimation = 0
  local volumeListener = nil ---@type fun()?
  
  ---@param item MumbleMMF.Channel
  ---@param parent MumbleMMF.Channel?
  local function finalizeChannel(item, parent)
    item.parent = parent
    channelsMap[item.id] = item
    for _, v in ipairs(item.children) do finalizeChannel(v, item) end
  end

  ---@param root MumbleMMF.Channel
  local function finalizeChannels(root)
    channelsData = root
    table.clear(channelsMap)
    if root then
      finalizeChannel(root, nil)
    end
  end

  local subscriptions = nil ---@type fun()[]?
  local listenerTooltip, listenerContextMenu
  
  local function syncState(dt)
    if mmf.numCurrentlyConnected < 1 or processError then
      if subscriptions then
        table.forEach(subscriptions, function (item) item() end)
        subscriptions = nil
      end
    elseif not subscriptions then
      subscriptions = {
        ui.onDriverTooltip(false, listenerTooltip),
        ui.onDriverContextMenu(listenerContextMenu),
        ac.onSharedEvent('app.csp.mumble', commandCallback, true)
      }
    end
    
    if processError then
      if mmf.frameIndex > 0 then
        restartFMODStreams()
        mmf.frameIndex = 0
        table.clear(talkingList)
        talkingCount = 0
        actualVolumeMultiplier = 1
        if volumeListener then
          volumeListener()
          volumeListener = nil
        end
        for i = 0, sim.carsCount - 1 do
          ac.setDriverMouthOpened(i, 0)
        end
      end
      return
    end

    if hideErrorMessage then
      hideErrorMessage = false
    end
  
    mmf.frameIndex = mmf.frameIndex + 1
    mmf.listenerPos:set(sim.cameraPosition)
    mmf.listenerDir:set(sim.cameraLook)
    mmf.listenerUp:set(sim.cameraUp)
    ownCar.bodyTransform:transformPointTo(mmf.audioSourcePos, ownCar.driverEyesPosition)
    mmf.pushToTalk = pushToTalkButton:down()
    mmf.positionlessInput = mumbleParams.useDirectPTT and mmf.pushToTalk or false

    local ownMuted = HAS_FLAG(mmf.currentlyConnected[0], FLAG_MUTED_ANY) and (config.inputMode ~= 'pushToTalk' or mmf.pushToTalk or mumbleParams.useDirectPTT)
    ownMutedAnimation = ownMutedSmoothness(ownMuted and 1 or 0)

    -- Now, holding PTT button overrides other detections, but we only need it in PPT-adaptive mode
    if config.inputMode ~= 'pushToTalk' and not mumbleParams.useDirectPTT then
      mmf.pushToTalk = false
    end

    if mmf.frameIndex > lastMicRequireFrame then
      mmf.requireMicPeak = false
    end
    if mmf.frameIndex > lastServerLoopbackFrame then
      mmf.serverLoopback = false
    end

    if talkingCount > 0 then
      table.clear(talkingList)
      talkingCount = 0
    end
    local volumeBoost = 1 / actualVolumeMultiplier
    local othersTalking = false

    local fadeOthers = config.audioFade_others
    local fadeOwn = config.audioFade_own
    for i = 0, mmf.numCurrentlyConnected - 1 do
      local user = mmf.currentlyConnected[i]
      local ex = getExtrasFor(tonumber(user.sessionID))
      if ex and ex:update(user, volumeBoost, dt) then
        talkingCount = talkingCount + 1
        talkingList[talkingCount] = ex

        local fade
        if ex.car.index > 0 or mmf.serverLoopback then
          fade = fadeOthers
        else
          fade = fadeOwn
        end
        if fade then
          othersTalking = true
        end
      end
    end

    volumeMultiplier = othersTalking and math.max(volumeMultiplier - dt * 3, 0) or math.min(volumeMultiplier + dt * 3, 1)
    actualVolumeMultiplier = math.lerp(config.audioFade, 1, volumeMultiplier)

    if actualVolumeMultiplier > 0.999 then
      if volumeListener then
        volumeListener()
        volumeListener = nil
      end
    elseif not volumeListener then
      volumeListener = ac.onAudioVolumeCalculation(function () return actualVolumeMultiplier end)
    end

    if commandsCount > 0 and mmf.numCommands == 0 and mmf.numCurrentlyConnected > 0 then
      local commandsToSubmit = math.min(commandsCount, DEF_MAX_COMMAND_COUNT)
      for i = 0, commandsToSubmit - 1 do
        ac.stringToFFIStruct(commandsList[i + 1], mmf.commands[i].value, DEF_COMMAND_SIZE)
      end
      mmf.numCommands = commandsToSubmit
      if commandsCount <= DEF_MAX_COMMAND_COUNT then
        table.clear(commandsList)
      else
        commandsList = table.slice(commandsList, DEF_MAX_COMMAND_COUNT + 1)
      end
      commandsCount = commandsCount - commandsToSubmit
    end

    if mmf.channelsPhase ~= lastChannelsPhase then
      lastChannelsPhase = mmf.channelsPhase
      finalizeChannels(JSON.parse(ffi.string(mmf.channelsData)))
    end
  end

  -- Controls

  local icons = ui.atlasIcons('res/icons.png', 4, 1, {
    FMOD = {1, 1},
    ZeroPercent = {1, 2},
    VolumeMuted = {1, 3},
  })

  local stateIconSize = vec2(8, 8)
  listenerTooltip = function(carIndex)
    local ex = getExtrasFor(carIndex)
    if ex then
      local icon, color, text
      if ex.driverConfig.muted then
        icon, color, text = ui.Icons.Ban, rgbm.colors.red, 'Muted by you'
      elseif ex.driverConfig.volume == 0 then
        icon, color, text = icons.ZeroPercent, rgbm.colors.yellow, 'Volume is set to 0%'
      elseif HAS_FLAG(ex, FLAG_SUPRESSED) then
        icon, color, text = ui.Icons.MicrophoneMuted, rgbm.colors.lime, 'Supressed'
      elseif HAS_FLAG(ex, FLAG_MUTED) then
        icon, color, text = ui.Icons.MicrophoneMuted, rgbm.colors.cyan, 'Muted'
      elseif HAS_FLAG(ex, FLAG_SELF_MUTED) then
        icon, color, text = ui.Icons.MicrophoneMuted, rgbm.colors.red, 'Self-muted'
      elseif HAS_FLAG(ex, FLAG_DEAF) then
        icon, color, text = icons.VolumeMuted, rgbm.colors.cyan, 'Deaf'
      elseif HAS_FLAG(ex, FLAG_SELF_DEAF) then
        icon, color, text = icons.VolumeMuted, rgbm.colors.red, 'Self-deaf'
      else
        if ex.talkingAnimation > 0.01 then
          text, icon = 'Currently speaking', ui.Icons.Microphone
        elseif ex:isMumbleConnected() then
          text, icon = 'Connected to voice chat', ui.Icons.Confirm
        else
          text, icon = 'Not connected to voice chat', ui.Icons.Cancel
        end
        color = ex:getMumbleColor()
      end

      ui.text(text)
      ui.sameLine(0, 4)
      ui.offsetCursorY(4)
      ui.icon(icon, stateIconSize, color)
    end
  end

  listenerContextMenu = function(carIndex)
    local ex = getExtrasFor(carIndex)
    if ex then
      ui.setNextItemWidth(ui.availableSpaceX())
      if ex.car.index == 0 then
        config.inputVolume = ui.slider('##volume', config.inputVolume * 100, 0, 200, 'Microphone volume: %.0f%%') / 100
        if ui.itemEdited() then
          addCommand('audio.inputDevice.volume', config.inputVolume)
        end
      else
        local newVolume = ui.slider('##volume', ex.driverConfig.volume * 100, 0, 200, 'Voice volume: %.0f%%') / 100
        if ui.itemEdited() then
          ex:configure({volume = newVolume})
        end
      end
    end
  end

  local contextMenuOpened = nil ---@type {[1]: CarData?, [2]: boolean}?
  local tmpVec2 = vec2()

  ---@param cur vec2
  ---@param icon ui.Icons
  ---@param color rgbm
  local function CtrlUsers_UserIcon(cur, threshold, icon, color, hint)
    if cur.x < threshold then return end
    ui.drawIcon(icon, cur, tmpVec2:set(cur):add(11), color)
    if ui.rectHovered(cur, tmpVec2) then
      ui.setTooltip(hint)
    end
    cur.x = cur.x - 16
  end

  local function CtrlUsers_Users(startFrom, channelID)
    for i = startFrom, mmf.numCurrentlyConnected - 1 do
      local user = mmf.currentlyConnected[i]
      if user.channelID == channelID then
        local ex = getExtrasFor(tonumber(user.sessionID) or -1)
        if ex and ex.car.isConnected then
          ui.offsetCursorX(10)
          ui.pushID(ex.sessionID)
          local cur = ui.getCursor()
          if ex.talkingAnimation > 0.01 then
            ui.drawIcon(ex.talkingIcon, ui.getCursor() + 2, ui.getCursor() + 13, ex:getMumbleColor())
          else
            ui.drawIcon(ui.Icons.User, ui.getCursor() + 2, ui.getCursor() + 13, ex:getMumbleColor())
          end
          ui.offsetCursorX(18)
          ui.textColored(ex.driverName, ex.driverTags and ex.driverTags.color or rgbm.colors.white)
          ui.sameLine()
          local t = ui.getCursorX()
          ui.setCursor(cur)
          ui.invisibleButton('##user', vec2(ui.availableSpaceX(), 17))

          if ui.itemHovered() then
            ui.setDriverTooltip(ex.car.index)
            if ui.itemClicked(ui.MouseButton.Left) then
              ui.mentionDriverInChat(ex.car.index, true)
            elseif ui.itemClicked(ui.MouseButton.Right) then
              contextMenuOpened = {ex, false}
            end
          end

          cur.y = cur.y + 2
          cur.x = cur.x + ui.availableSpaceX() - 20

          if ex.driverConfig.muted then
            CtrlUsers_UserIcon(cur, t, ui.Icons.Ban, rgbm.colors.red, 'Muted by you')
          elseif ex.driverConfig.volume == 0 then
            CtrlUsers_UserIcon(cur, t, icons.ZeroPercent, rgbm.colors.yellow, 'Volume is set to 0%')
          end
          if HAS_FLAG(user, FLAG_SUPRESSED) then
            CtrlUsers_UserIcon(cur, t, ui.Icons.MicrophoneMuted, rgbm.colors.lime, 'Supressed')
          end
          if HAS_FLAG(user, FLAG_MUTED) then
            CtrlUsers_UserIcon(cur, t, ui.Icons.MicrophoneMuted, rgbm.colors.cyan, 'Muted')
          elseif HAS_FLAG(user, FLAG_SELF_MUTED) then
            CtrlUsers_UserIcon(cur, t, ui.Icons.MicrophoneMuted, rgbm.colors.red, 'Self-muted')
          end
          if HAS_FLAG(user, FLAG_DEAF) then
            CtrlUsers_UserIcon(cur, t, icons.VolumeMuted, rgbm.colors.cyan, 'Deaf')
          elseif HAS_FLAG(user, FLAG_SELF_DEAF) then
            CtrlUsers_UserIcon(cur, t, icons.VolumeMuted, rgbm.colors.red, 'Self-deaf')
          end

          ui.popID()
        end
      end
    end
  end

  -- ui.beginTreeNode = function () return true end
  -- ui.endTreeNode = function () end

  ---@param channel MumbleMMF.Channel 
  local function CtrlUsers_Channel(channel)
    if channel.description == 'hidden' then
      return
    end

    local start = ui.getCursor()
    local hovered = ui.rectHovered(start, start + vec2(ui.availableSpaceX(), 17))

    if #channel.children == 0 and channel.currentUsers == 0 then
      ui.offsetCursorX(30)
      ui.text(channel.name)
    else
      local firstConnected = 256
      for i = 0, mmf.numCurrentlyConnected - 1 do
        if mmf.currentlyConnected[i].channelID == channel.id then
          firstConnected = i
          break
        end
      end

      if firstConnected == 0 then
        ui.pushStyleColor(ui.StyleColor.Text, rgbm(1, 1, 0, 1))
      end

      local flags = ui.TreeNodeFlags.OpenOnArrow
      if channel.currentUsers > 0 then flags = bit.bor(flags, ui.TreeNodeFlags.DefaultOpen) end
      if channel.parent == nil then flags = bit.bor(flags, ui.TreeNodeFlags.NoArrow, ui.TreeNodeFlags.NoTreePushOnOpen) end
      if ui.beginTreeNode(channel.currentUsers > 0 and string.format('%s (%d)', channel.name, channel.currentUsers) or channel.name, flags) then
        if firstConnected == 0 then
          ui.popStyleColor()
        end
        using(function ()
          for _, v in ipairs(channel.children) do
            CtrlUsers_Channel(v)
          end
          CtrlUsers_Users(firstConnected, channel.id)
        end, channel.parent ~= nil and ui.endTreeNode or nil)
      else
        if firstConnected == 0 then
          ui.popStyleColor()
        end
      end
    end

    start.x = start.x + ui.availableSpaceX() - 10
    start.y = start.y + 2

    if channel.description then
      ui.drawIcon(ui.Icons.Notifications, start, start + 11, rgbm.colors.yellow)
      start.x = start.x - 16
      if hovered then
        ui.setTooltip(channel.description)
      end
    end

    if not channel.canEnter then
      ui.drawIcon(ui.Icons.Padlock, start, start + 11, rgbm.colors.red)
      start.x = start.x - 16
    elseif channel.isEnterRestricted then
      ui.drawIcon(ui.Icons.PadlockUnlocked, start, start + 11, rgbm.colors.red)
      start.x = start.x - 16
    end

    if channel.maxUsers > 0 and channel.directUsers >= channel.maxUsers then
      ui.drawIcon(ui.Icons.Group, start, start + 11, rgbm.colors.white)
    end

    if hovered and uiState.isMouseLeftKeyDoubleClicked then
      makeAnAttempt('change channel')
      addCommand('user.channel', channel.name)
    end
  end

  local function CtrlUsers()
    if channelsData == nil then
      ac.setWindowTitle('main', 'Voice Chat')
      ui.textWrapped('Getting channel data…')
    elseif #channelsData.children > 0 then
      local ownChannel = channelsMap[mmf.currentlyConnected[0].channelID]
      if ownChannel and ownChannel.description == 'hidden' then
        ac.setWindowTitle('main', 'Voice Chat')
        CtrlUsers_Users(0, ownChannel.id)
      else
        ac.setWindowTitle('main', '')
        ui.offsetCursorX(-12)
        ui.offsetCursorY(-26)
        ui.pushClipRect(vec2(0, ui.getScrollY()), ui.windowSize():add(vec2(0, ui.getScrollY())), false)
        for _, v in pairs(channelsMap) do
          v.currentUsers = 0
          v.directUsers = 0
        end
        for i = 0, mmf.numCurrentlyConnected - 1 do
          local c = channelsMap[mmf.currentlyConnected[i].channelID]
          if c ~= nil then
            c.directUsers = c.directUsers + 1
            while c ~= nil do
              c.currentUsers = c.currentUsers + 1
              c = c.parent
            end
          end
        end
        -- ui.setMaxCursorY(ui.getCursorY)
        CtrlUsers_Channel(channelsData)
        ui.popClipRect()
      end
    else
      ac.setWindowTitle('main', 'Voice Chat')
      CtrlUsers_Users(0, channelsData.id)
    end
  end

  local overlayItemSize = vec2(160, 24)
  local overlayItemColor = rgbm(0, 0, 0, 0.5)
  local overlayItemHoveredColor = rgbm(0, 0, 0, 0.6)
  local overlayIconPos1 = vec2(8, 4)
  local overlayIconPos2 = vec2(24, 20)
  local overlayIconPosR = vec2(8 + 8.3, 4 + 8.3)
  local overlayTextOffset = vec2(30, 4)
  local overlayButtonsRect1 = vec2(0, -24)
  local overlayButtonsRect2 = vec2(160, 0)
  local errorSmoothness = ui.SmoothInterpolation(0, 1)
  local errorAnimation = 0
  local popupButtonsSmoothness = ui.SmoothInterpolation(0, 1)
  local popupButtonsAnimation = 0
  local fadeWindowIn = false
  local needsButton = false

  local function OverlayRect(pos1, pos2)
    local hovered = ui.rectHovered(pos1, pos2)
    ui.drawRectFilled(pos1, pos2, hovered and overlayItemHoveredColor or overlayItemColor, 2)
    return hovered
  end

  local function CtrlTalkingUsers_List()
    local cur = vec2()
    local windowHovered = ui.windowHovered()
    local buttonsHovered = ui.rectHovered(overlayButtonsRect1, overlayButtonsRect2, false)
    local hovered = windowHovered or buttonsHovered

    if popupButtonsAnimation > 0.01 then
      ui.pushStyleVarAlpha(popupButtonsAnimation)
      ui.pushClipRect(vec2(0, -30 * popupButtonsAnimation), vec2(160, 0), false)

      local btn1Hovered = OverlayRect(vec2(6, -24), vec2(26, -4))
      ui.drawIcon(ui.Icons.List, vec2(9, -21), vec2(23, -7))
      if btn1Hovered then
        ui.setTooltip(ac.isWindowOpen('main') and 'Hide voice chat list' or 'Show voice chat list')
        if ui.mouseClicked() then
          ac.setWindowOpen('main', not ac.isWindowOpen('main'))
          fadeWindowIn = true
        end
      end

      local btn2Hovered = OverlayRect(vec2(30, -24), vec2(50, -4))
      ui.drawIcon(ui.Icons.Settings, vec2(33, -21), vec2(47, -7))
      if btn2Hovered then
        ui.setTooltip(ac.isWindowOpen('main.settings') and 'Hide voice chat settings' or 'Show voice chat settings')
        if ui.mouseClicked() then
          ac.setWindowOpen('main.settings', not ac.isWindowOpen('main.settings'))
        end
      end
      ui.popClipRect()
      ui.popStyleVar()
    end
    popupButtonsAnimation = popupButtonsSmoothness(hovered and 1 or 0)

    if processError or needsButton then
      local height = 30 * errorAnimation
      errorAnimation = errorSmoothness(hideErrorMessage and 0 or 1)
      if height - 6 > 0.5 then
        if errorAnimation < 0.99 then
          ui.pushClipRect(cur, cur + vec2(160, height - 6), true)
        end
        if processError then
          if processError.timeOfAnotherAttempt then
            local itemHovered = OverlayRect(cur, cur + overlayItemSize)
            ui.beginRotation()
            ui.drawIcon(ui.Icons.Loading, cur + overlayIconPos1, cur + overlayIconPos2, rgbm.colors.white)
            ui.endPivotRotation(math.round(os.preciseClock() * -10) * 45, cur + overlayIconPosR)
            ui.drawText('Voice chat issues…', cur + overlayTextOffset, rgbm.colors.white)
            if itemHovered then
              local timeLeft = processError.timeOfAnotherAttempt - os.preciseClock()
              ui.setTooltip(string.format('%s\nNext attempt: %.1f s\nClick to dismiss', processError.message, math.max(timeLeft, 0)))
              if ui.mouseClicked() then
                hideErrorMessage = true
              end
            end
          else
            local itemHovered = OverlayRect(cur, cur + overlayItemSize)
            ui.drawIcon(ui.Icons.Warning, cur + overlayIconPos1, cur + overlayIconPos2, rgbm.colors.white)
            ui.drawText('Voice chat error', cur + overlayTextOffset, rgbm.colors.white)
            if itemHovered then
              ui.setTooltip(processError.message..'\nClick to dismiss')
              if ui.mouseClicked() then
                hideErrorMessage = true
              end
            end
          end
        else
          local itemHovered = OverlayRect(cur, cur + overlayItemSize)
          ui.drawIcon(ui.Icons.Info, cur + overlayIconPos1, cur + overlayIconPos2, rgbm.colors.white)
          ui.drawText('Configure voice chat', cur + overlayTextOffset, rgbm.colors.white)
          if itemHovered then
            ui.setTooltip('Choose automatic input mode or configure Push-to-Talk button\nClick to open settings')
            if ui.mouseClicked() then
              config.settingsShown = true
              ac.setWindowOpen('main.settings', true)
            end
          end
        end
        if errorAnimation < 0.99 then
          ui.popClipRect()
        end
      end
      return
    end

    if ownMutedAnimation > 0.01 then
      local height = 30 * ownMutedAnimation
      if height - 6 > 0.5 then
        if ownMutedAnimation < 0.99 then
          ui.pushClipRect(cur, cur + vec2(160, height - 6), true)
        end
        local itemHovered = OverlayRect(cur, cur + overlayItemSize)
        ui.drawIcon(ui.Icons.MicrophoneMuted, cur + overlayIconPos1, cur + overlayIconPos2, rgbm.colors.red)
        ui.drawText('You’re muted', cur + overlayTextOffset, rgbm.colors.white)
        if itemHovered then
          ui.setTooltip('This state is controlled externally, either by a server config, by a script or by a Mumble server admin')
        end
        if ownMutedAnimation < 0.99 then
          ui.popClipRect()
        end
      end
    end

    for i = 1, talkingCount do
      local ex = talkingList[i]
      if ex.talkingAnimation > 0.01 then
        local height = 30 * ex.talkingAnimation
        if height - 6 > 0.5 then
          if ex.talkingAnimation < 0.99 then
            ui.pushClipRect(cur, cur + vec2(160, height - 6), true)
          end
          local itemHovered = OverlayRect(cur, cur + overlayItemSize)
          ui.drawImage(ex.talkingIcon, cur + overlayIconPos1, cur + overlayIconPos2)
          ui.drawText(ex.driverName, cur + overlayTextOffset, ex.driverTags.color)
          if itemHovered then
            ui.setDriverTooltip(ex.car.index)
            if ui.itemClicked(ui.MouseButton.Left) then
              ui.mentionDriverInChat(ex.car.index, true)
            elseif ui.itemClicked(ui.MouseButton.Right) then
              contextMenuOpened = {ex, false}
            end
          end
          if ex.talkingAnimation < 0.99 then
            ui.popClipRect()
          end
        end
        cur.y = cur.y + 30 * ex.talkingAnimation
      end
    end

    if contextMenuOpened then
      if not contextMenuOpened[2] then
        ui.openPopup('driverContextMenu')
        contextMenuOpened[2] = true
      end
      if not ui.setDriverPopup('driverContextMenu', contextMenuOpened[1].car.index) then
        contextMenuOpened = nil
      end
    end
  end

  local usersWindowPos = vec2(0, 40)
  local usersWindowSize = vec2(160, 0)
  local function CtrlTalkingUsers()
    needsButton = not config.settingsShown and config.inputMode == 'pushToTalk' and not pushToTalkButton:configured() and mmf.numCurrentlyConnected > 1 and mmf.numInputDevices > 0
    local size = (processError or needsButton) and (hideErrorMessage and errorAnimation < 0.01 and 0 or 1)
      or (talkingCount + ((ownMutedAnimation > 0.01 or popupButtonsAnimation > 0.01) and 1 or 0))
    if popupButtonsAnimation > 0.01 and size == 0 then size = 1 end
    if size == 0 and contextMenuOpened == nil then return end
    usersWindowPos.x, usersWindowSize.y = uiState.windowSize.x - 180, 30 * size
    ui.setCursor(usersWindowPos)
    ui.childWindow('mumble_Talking', usersWindowSize, false, 0, CtrlTalkingUsers_List)
  end

  ---@param num integer
  ---@param list MumbleMMF.Device[]
  ---@param key string
  ---@param fmodOutput boolean?
  local function CtrlDeviceList(num, list, key, fmodOutput)
    if ui.frameCount() % 32 == 0 then
      addCommand('action.updateDevices')
    end

    if num == 0 and not fmodOutput then
      ui.pushDisabled()
      ui.combo('##'..key, 'No devices found', function () end)
      ui.popDisabled()
      return
    end
  
    local selectedName, selectedIcon
    if fmodOutput and config.outputFMOD then
      selectedName = 'FMOD'
      selectedIcon = icons.FMOD
    else
      local selected = list[0]
      for i = 0, num - 1 do
        if HAS_FLAG(list[i], FLAG_SELECTED) then
          selected = list[i]
        end
      end
      selectedName = ffi.string(selected.name)
      selectedIcon = ffi.string(selected.icon)
    end

    ui.combo('##'..key, '\t '..selectedName, function ()
      if fmodOutput then
        if ui.selectable('\t  FMOD', config.outputFMOD) and not config.outputFMOD then
          addCommand('system.streamConnectPointsPrefix', mmfKey..'.')
          config.outputFMOD = true
        end
        if ui.itemHovered() then
          ui.setTooltip('Use in-game audio for better spatial playback')
        end
        ui.addIcon(icons.FMOD, vec2(16, 16), vec2(0, 0.5))
      end

      for i = 0, num - 1 do
        local name = ffi.string(list[i].name)
        if ui.selectable('\t  '..name, list[i] == selectedName) then
          addCommand('audio.'..key, name)
          if fmodOutput and config.outputFMOD then
            addCommand('system.streamConnectPointsPrefix', nil)
            config.outputFMOD = false
            restartFMODStreams()
          end
          config[key] = name
          ac.warn(key, name)
        end
        ui.addIcon(ffi.string(list[i].icon), vec2(16, 16), vec2(0, 0.5))
      end
    end)
    ui.addIcon(selectedIcon, vec2(16, 16), vec2(0, 0.5))
  end

  local settingsInputModes = {
    { value = 'alwaysSend', name = 'Continuous', description = 'Stream audio constantly', settings = function () end },
    { value = 'pushToTalk', name = 'Push-to-Talk', description = 'Hold a button to talk', settings = function (generic)
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      ui.alignTextToFramePadding()
      ui.text(generic and 'PTT:' or 'Button:')
      ui.sameLine(200)
      pushToTalkButton:control(vec2(ui.availableSpaceX(), 0))
    end },
    { value = 'amplitude', name = 'Amplitude threshold', description = 'Send audio if a certain threshold is reached', settings = function ()
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      ui.pushItemWidth(ui.availableSpaceX())
      config.inputMode_holdSeconds = ui.slider('##hold', config.inputMode_holdSeconds, 0, 5, 'Hold for: %.1f s')
      if ui.itemEdited() then addCommand('audio.inputMode.holdSeconds', config.inputMode_holdSeconds) end
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      config.inputMode_amplitude_minValue = ui.slider('##amv', config.inputMode_amplitude_minValue * 100, 0, 10, 'Threshold: %.1f%%') / 100
      if ui.itemEdited() then addCommand('audio.inputMode.amplitude.minValue', config.inputMode_amplitude_minValue) end
    end },
    { value = 'voiceActivity', name = 'Voice detection', description = 'Uses SpeexDSP library to detect voice', settings = function () 
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      ui.pushItemWidth(ui.availableSpaceX())
      config.inputMode_holdSeconds = ui.slider('##hold', config.inputMode_holdSeconds, 0, 5, 'Hold for: %.1f s')
      if ui.itemEdited() then addCommand('audio.inputMode.holdSeconds', config.inputMode_holdSeconds) end
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      config.inputMode_voiceActivity_start = ui.slider('##vas', config.inputMode_voiceActivity_start, 0, 100, 'Start probability: %.0f%%')
      if ui.itemEdited() then addCommand('filter.speexDSP.voiceActivityDetector.start', config.inputMode_voiceActivity_start) end
      if ui.itemHovered() then ui.setTooltip('Probability required for SpeexDSP to detect voice for the first time') end
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      config.inputMode_voiceActivity_continue = ui.slider('##vac', config.inputMode_voiceActivity_continue, 0, 100, 'Continue probability: %.0f%%')
      if ui.itemEdited() then addCommand('filter.speexDSP.voiceActivityDetector.continue', config.inputMode_voiceActivity_continue) end
      if ui.itemHovered() then ui.setTooltip('Probability required for SpeexDSP to continue detecting voice') end
    end },
  }

  local settingsTabs = {
    { name = 'Recording', icon = ui.Icons.Microphone, content = function ()
      lastMicRequireFrame = mmf.frameIndex + 4
      mmf.requireMicPeak = true

      ui.alignTextToFramePadding()
      ui.text('Device:')
      ui.sameLine(SETTINGS_COLUMN_WIDTH)
      ui.pushItemWidth(ui.availableSpaceX())
      CtrlDeviceList(mmf.numInputDevices, mmf.inputDevices, 'inputDevice')
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      ui.setNextItemWidth(ui.availableSpaceX())
      config.inputVolume = ui.slider('##inputVolume', config.inputVolume * 100, 0, 200, 'Volume: %.0f%%') / 100
      if ui.itemEdited() then
        addCommand('audio.inputDevice.volume', config.inputVolume)
      end

      ui.offsetCursorY(20)
      
      ui.text('Mode:')
      ui.sameLine(SETTINGS_COLUMN_WIDTH)
      ui.setNextItemWidth(ui.availableSpaceX())
      local currentMode = table.findFirst(settingsInputModes, function (item) return item.value == config.inputMode end) or settingsInputModes[1]
      ui.combo('##mode', currentMode.name, ui.ComboFlags.None, function ()
        for _, v in ipairs(settingsInputModes) do
          if ui.selectable(v.name, v == currentMode) then
            config.inputMode = v.value
            addCommand('audio.inputMode', config.inputMode)
            addCommand('filter.speexDSP', needsSpeexDSP())
            addCommand('filter.speexDSP.voiceActivityDetector', config.inputMode == 'voiceActivity')
          end
          if ui.itemHovered() then
            ui.setTooltip(v.description)
          end
        end
      end)
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      if HAS_FLAG(mmf.currentlyConnected[0], FLAG_IMMEDIATE_TALKING) then
        ui.pushStyleColor(ui.StyleColor.PlotHistogram, rgbm.colors.cyan)
        ui.progressBar(mmf.micPeak, vec2(ui.availableSpaceX(), 2))
        ui.popStyleColor()
      else
        ui.progressBar(mmf.micPeak, vec2(ui.availableSpaceX(), 2))
      end
      currentMode.settings()
      if mumbleParams.useDirectPTT and config.inputMode ~= 'pushToTalk' then
        settingsInputModes[2].settings(true)
        ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
        if ui.textHyperlink('Push-To-Talk?') then
          showAdaptivePTTNote()
        end
      end

      ui.offsetCursorY(20)
      
      ui.text('Quality:')
      ui.sameLine(SETTINGS_COLUMN_WIDTH)
      ui.setNextItemWidth(ui.availableSpaceX())
      local newBitrate, bitrateChanged = ui.slider('##bitrate', mmf.bitrate / 1e3, 8, 96, 'Compression bitrate: %.0f KHz')
      if bitrateChanged then
        mmf.bitrate = math.max(math.round(newBitrate) or 8, 1) * 1e3
        config.inputBitrate = mmf.bitrate
        addCommand('audio.inputBitrate', mmf.bitrate)
      end

      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      if ui.checkbox('SpeexDSP noise supression', config.inputDenoise_speex) then
        config.inputDenoise_speex = not config.inputDenoise_speex
        addCommand('filter.speexDSP', needsSpeexDSP())
        addCommand('filter.speexDSP.denoise', config.inputDenoise_speex)
      end
      if config.inputDenoise_speex then
        ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
        config.inputDenoise_speexSuppress = -ui.slider('##speexamount', -config.inputDenoise_speexSuppress, 1, 99, 'Strength: -%.0f dB')
        if ui.itemEdited() then
          addCommand('filter.speexDSP.denoise.suppress', config.inputDenoise_speexSuppress)
        end
      end
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      if ui.checkbox('RNNoise noise supression', config.inputDenoise_rnn) then
        config.inputDenoise_rnn = not config.inputDenoise_rnn
        addCommand('filter.rnnNoise', config.inputDenoise_rnn)
      end
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      if ui.checkbox('Echo cancellation', config.inputEchoCancellation) then
        config.inputEchoCancellation = not config.inputEchoCancellation
        addCommand('filter.speexDSP', needsSpeexDSP())
        addCommand('filter.speexDSP.echo', config.inputEchoCancellation)
      end
      if config.inputEchoCancellation then
        ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
        config.inputEchoCancellation_suppress = -ui.slider('##speexechosuppress', -config.inputEchoCancellation_suppress, 1, 99, 'Echo suppress: -%.0f dB')
        if ui.itemEdited() then
          addCommand('filter.speexDSP.echo.suppress', config.inputEchoCancellation_suppress)
        end
        ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
        config.inputEchoCancellation_suppressActive = -ui.slider('##speexechosuppressactive', -config.inputEchoCancellation_suppressActive, 1, 99, 'Echo suppress (active): -%.0f dB')
        if ui.itemEdited() then
          addCommand('filter.speexDSP.echo.suppress', config.inputEchoCancellation_suppressActive)
        end
      end

      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      if ui.checkbox('Automatic gain control', config.inputAutoGainControl) then
        config.inputAutoGainControl = not config.inputAutoGainControl
        addCommand('filter.speexDSP', needsSpeexDSP())
        addCommand('filter.speexDSP.autoGainControl', config.inputAutoGainControl)
      end
      if config.inputAutoGainControl then
        ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
        config.inputAutoGainControl_level = ui.slider('##speexagc', config.inputAutoGainControl_level/1000, 1, 99, 'AGC level: %.0fk') * 1000
        if ui.itemEdited() then
          addCommand('filter.speexDSP.autoGainControl.level', config.inputAutoGainControl_level)
        end
      end
    end },
    { name = 'Playback', icon = ui.Icons.VolumeHigh, content = function ()

      ui.alignTextToFramePadding()
      ui.text('Device:')
      ui.sameLine(SETTINGS_COLUMN_WIDTH)
      ui.pushItemWidth(ui.availableSpaceX())
      CtrlDeviceList(mmf.numOutputDevices, mmf.outputDevices, 'outputDevice', true)
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      ui.setNextItemWidth(ui.availableSpaceX())
      config.outputVolume = ui.slider('##outputVolume', config.outputVolume * 100, 0, 200, 'Volume: %.0f%%') / 100
      if ui.itemEdited() then
        addCommand('audio.outputDevice.volume', config.outputVolume)
        updateFMODStreamsVolume()
      end
  
      if config.outputFMOD then
        ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
        if ui.checkbox('Doppler effect', config.outputDoppler) then
          config.outputDoppler = not config.outputDoppler
          restartFMODStreams()
        end
        if ui.itemHovered() then ui.setTooltip('Add a bit of pitch offset based on audio source velocity (applies on servers with 3D audio)') end
        ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
        if ui.checkbox('Reverb effect', config.outputReverb) then
          config.outputReverb = not config.outputReverb
          restartFMODStreams()
        end
        if ui.itemHovered() then ui.setTooltip('Add echo effect in tunnels and such based on track configuration (applies on servers with 3D audio)') end
      end

      ui.offsetCursorY(20)
      ui.alignTextToFramePadding()
      ui.text('Attenuation:')
      ui.sameLine(SETTINGS_COLUMN_WIDTH)
      ui.setNextItemWidth(ui.availableSpaceX())
      config.audioFade = ui.slider('##audioFade', config.audioFade * 100, 0, 100, 'AC audio fade: %.0f%%') / 100
      if ui.itemHovered() then ui.setTooltip('Lower the rest of audio volume if anybody is currently talking') end

      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      if ui.checkbox('While other drivers talk', config.audioFade_others) then
        config.audioFade_others = not config.audioFade_others
      end
      ui.offsetCursorX(SETTINGS_COLUMN_WIDTH)
      if ui.checkbox('While you talk', config.audioFade_own) then
        config.audioFade_own = not config.audioFade_own
      end
    end },
    { name = 'Network', icon = ui.Icons.Earth, content = function ()
      if ui.checkbox('Force TCP mode', config.systemForceTCP) then
        config.systemForceTCP = not config.systemForceTCP
        addCommand('system.forceTCP', config.systemForceTCP)
      end
      if ui.checkbox('Use Quality of Service to increase packets priority', config.systemSetQOS) then
        config.systemSetQOS = not config.systemSetQOS
        addCommand('system.setQOS', config.systemSetQOS)
      end
    end, availableDisconnected = true },
    { name = 'Log', icon = ui.Icons.ListAlt, content = function ()
      ui.pushFont(ui.Font.Monospace)
      ui.setScrollY(1e9)
      ui.textWrapped(table.concat(logItems))
      ui.popFont()
    end, availableDisconnected = true },
  }
  local settingsSelectedTab = settingsTabs[tonumber(ac.storage.selectedSettingsTab) or 1]
  local tabIconSize = vec2(16, 16)

  local function CtrlGeneralSettings()
    lastServerLoopbackFrame = mmf.frameIndex + 4

    ui.pushClipRect(vec2(), ui.windowSize(), false)
    ui.offsetCursorX(-20)
    ui.childWindow('##items', vec2(120, 400), function ()
      ui.offsetCursorY(10)
      ui.pushStyleVar(ui.StyleVar.SelectablePadding, vec2(40, 0))
      ui.pushStyleVar(ui.StyleVar.ItemSpacing, vec2(0, 20))
      for i, v in ipairs(settingsTabs) do
        if ui.selectable(v.name, v == settingsSelectedTab) then
          settingsSelectedTab = v
          ac.storage.selectedSettingsTab = i
        end
        ui.addIcon(v.icon, tabIconSize, vec2(0.08, 0.5))
      end
      ui.popStyleVar(2)

      ui.offsetCursorY(ui.availableSpaceY() - 30)
      ui.offsetCursorX(20)
      ui.pushFont(ui.Font.Small)
      if ui.checkbox('Loopback', mmf.serverLoopback) then
        mmf.serverLoopback = not mmf.serverLoopback
      end
      if ui.itemHovered() then
        ui.setTooltip('Use this option for server to send your audio back to you and compare different audio settings')
      end
      ui.popFont()
    end)
    ui.sameLine(0, 8)
    if mmf.numCurrentlyConnected > 0 or settingsSelectedTab.availableDisconnected then
      ui.childWindow('##settings', vec2(340, 400), settingsSelectedTab.content)
    else
      ui.childWindow('##settings', vec2(340, 400), function ()
        local p = ui.windowSize():scale(0.5)
        p.x, p.y = p.x - 20, p.y - 60
        if not processError or processError.timeOfAnotherAttempt then
          ui.drawLoadingSpinner(p, p + 40)
          ui.textAligned('Awaiting voice chat connection…', vec2(0.5, 0.5), ui.availableSpace())
        else
          ui.drawIcon(ui.Icons.Warning, p, p + 40)
          ui.textAligned('Failed to establish voice chat connection', vec2(0.5, 0.5), ui.availableSpace())
        end
      end)
    end
    ui.popClipRect()
  end

  local function CtrlWindow_Content(fading)
    if processError then
      ac.setWindowTitle('main', 'Voice Chat')
      ui.header(processError.timeOfAnotherAttempt and 'Error' or 'Fatal error')
      ui.textWrapped(processError.message)
      if processError.timeOfAnotherAttempt then
        ui.offsetCursorY(8)
        local timeLeft = processError.timeOfAnotherAttempt - os.preciseClock()
        ui.textWrapped(string.format('Next attempt: %.1f s', math.max(timeLeft, 0)))
      end
      return
    end

    if mmf.numCurrentlyConnected < 1 then
      ac.setWindowTitle('main', 'Voice Chat')
      ui.pushStyleVarAlpha(1 - fading * 0.9)
      local p = ui.windowSize():scale(0.5):sub(20)
      ui.drawLoadingSpinner(p, p + 40)
      ui.popStyleVar()
      return
    end

    CtrlUsers()
  end

  local function CtrlWindow()
    if fadeWindowIn then
      fadeWindowIn = false
      ac.forceFadingIn()
    end
    local fading = ac.windowFading()
    if fading > 0.01 then
      ui.beginOutline()
      CtrlWindow_Content(fading)
      ui.endOutline(rgbm(0, 0, 0, fading))
    else
      CtrlWindow_Content(0)
    end
  end

  ac.onRelease(function ()
    for i = 0, sim.carsCount - 1 do
      ac.setDriverMouthOpened(i, 0)
    end
  end)

  return {
    update = syncState,
    main = CtrlWindow,
    settings = CtrlGeneralSettings,
    fullscreen = CtrlTalkingUsers
  }
end

