Keyboard Foley

@rebindv0.1.3

Add Mechanical, Typewriter, or Soft Pop sounds to ordinary typing without Rebind Link. F8 mutes; F9 changes the theme.

Files

main.luau

local cfg = UI.Schema({
  enabled = UI.Toggle(true, {
    label = "Typing sounds",
    group = "Keyboard Foley",
    tooltip = "The mute key mutes or unmutes this package. Typing is not remapped.",
  }),
  muteKey = UI.Keybind("F8", { label = "Mute key", group = "Keyboard Foley" }),
  theme = UI.Select("Mechanical", { "Mechanical", "Typewriter", "Soft Pop" }, {
    label = "Sound theme",
    group = "Sound",
    tooltip = "The theme key cycles the three original synthesized themes.",
  }),
  themeKey = UI.Keybind("F9", { label = "Theme key", group = "Sound" }),
  volume = UI.Slider(35, {
    label = "Volume",
    group = "Sound",
    min = 0,
    max = 100,
    step = 5,
    suffix = "%",
  }),
  shortcuts = UI.Toggle(false, {
    label = "Sound with Ctrl, Alt, Cmd, or Win",
    group = "Typing",
    tooltip = "Off keeps these shortcuts quiet. Shifted typing still makes sound.",
  }),
})

local NEXT_THEME = {
  Mechanical = "Typewriter",
  Typewriter = "Soft Pop",
  ["Soft Pop"] = "Mechanical",
}
local FOLDERS = {
  Mechanical = "mechanical",
  Typewriter = "typewriter",
  ["Soft Pop"] = "soft-pop",
}
local SPECIAL = {
  Space = "space",
  Enter = "enter",
  KpEnter = "enter",
  Backspace = "backspace",
}
local TYPING_KEYS = {
  Tab = true,
  Minus = true,
  Equal = true,
  LeftBrace = true,
  RightBrace = true,
  Backslash = true,
  Semicolon = true,
  Apostrophe = true,
  Grave = true,
  Comma = true,
  Period = true,
  Slash = true,
  KpPlus = true,
  KpMinus = true,
  KpMultiply = true,
  KpDivide = true,
  KpDot = true,
}
local MAX_VOICES = 12
local voices = {}
local variation = 0
local last_theme = cfg.theme
local last_volume = nil

local function volume()
  local value = cfg.volume
  if type(value) ~= "number" or value ~= value then
    value = 35
  end
  return math.max(0, math.min(100, value)) / 100
end

local function silence()
  for _, sound in voices do
    sound:Stop()
  end
  voices = {}
end

local function prune()
  for i = #voices, 1, -1 do
    if not voices[i]:IsPlaying() then
      table.remove(voices, i)
    end
  end
end

local function update_sounds()
  if cfg.enabled ~= true or cfg.theme ~= last_theme then
    silence()
    last_theme = cfg.theme
  end
  prune()
  local level = volume()
  if level ~= last_volume then
    for _, sound in voices do
      sound:SetVolume(level)
    end
    last_volume = level
  end
end

Bind(cfg.muteKey, function()
  cfg.enabled = cfg.enabled ~= true
  silence()
  UI.Notify(
    cfg.enabled and "Keyboard Foley unmuted." or "Keyboard Foley muted."
  )
end)

Bind(cfg.themeKey, function()
  cfg.theme = NEXT_THEME[cfg.theme] or "Mechanical"
  silence()
  UI.Notify("Keyboard Foley: " .. cfg.theme .. ".")
end)

-- observers cannot swallow input and do not require exclusive key capture.
function OnInputDown(key)
  update_sounds()
  if cfg.enabled ~= true then
    return
  end
  local level = volume()
  if level == 0 then
    return
  end
  local sample = SPECIAL[key]
  if not sample then
    if
      not TYPING_KEYS[key]
      and not key:match("^[A-Z0-9]$")
      and not key:match("^Kp[0-9]$")
    then
      return
    end
    variation = variation % 3 + 1
    sample = "key-" .. variation
  end
  if cfg.shortcuts ~= true then
    local modifiers = Input.GetModifiers()
    if modifiers.ctrl or modifiers.alt or modifiers.win then
      return
    end
  end
  if #voices == MAX_VOICES then
    table.remove(voices, 1):Stop()
  end
  local folder = FOLDERS[cfg.theme] or "mechanical"
  local ok, sound =
    pcall(Audio.Play, "samples/" .. folder .. "/" .. sample .. ".wav", {
      volume = level,
    })
  if not ok then
    cfg.enabled = false
    silence()
    Log.Error("Keyboard Foley could not play a sample: " .. tostring(sound))
    UI.Notify(
      `Keyboard Foley muted after an audio error. Check Logs, then press {cfg.muteKey} to retry.`,
      "error"
    )
    return
  end
  -- retain handles until playback ends; collecting one stops its native sound.
  table.insert(voices, sound)
end

function OnTick()
  update_sounds()
end

function OnStop()
  silence()
end

18 files without text previews