Perch
Lua APIAudio

Audio

perch.audio: the output volume, the spectrum of what is playing, shipped sounds, the per-application mixer, the microphone indicator, and the audio types.

5 min readUpdated Sep 9, 2026

perch.audio is everything about sound on the machine. Each piece has its own scope, so a mod that only wants a chime never asks for the mixer.

ScopeInstalls
volume.controlperch.audio.volume.up(), .down(), .set(level)
system.transientsperch.audio.volume.on_change
mediaperch.audio.spectrum(bands?)
audio.playperch.audio.play(file)
audio.mixerperch.audio.sessions(), .set_session_volume(pid, level), .set_session_muted(pid, muted)
audio.microphoneperch.audio.microphone.read()

perch.audio.volume

perch.audio.volume.up(): ()
perch.audio.volume.down(): ()
perch.audio.volume.set(level: number): ()
perch.audio.volume.on_change: signal_t<volume_state_t>

up and down step by 10 points; set takes an integer percent 0..100. The change echoes back on on_change, which system.transients grants because it is part of the HUD stream; a mod that wants both the slider and the readout asks for both scopes. on_change fires on every change of at least 0.004 and on mute flips, with no baseline event at start.

The voice intents volume_up, volume_down and set_volume map onto the same verbs. A wheel over a node is the other natural input:

ui.row { on_scroll = function(ctx: input_context_t) perch.audio.volume.set(level + (ctx.steps or 0) * 2) end, ... }

perch.audio.spectrum

perch.audio.spectrum(bands: number?): spectrum_t

The frequency bands Perch computes for its own visualizer, resampled to the count you ask for (1 to 64, default 6), ordered low to high, each 0..1. Granted by the media scope because it is the spectrum of what is playing. Synchronous; poll it from a timer while your display is visible.

--!strict
type fields_t = { levels: field_ref_t<{ number }> }

local bars = widget.new { id = "bars", label = "Spectrum", fields = { levels = field.numbers() } }
bars:place(surface.page, function(f: fields_t): node_t
  return ui.visualizer { levels = f.levels, bars = 6, palette = ui.palette.art, h = 24 }
end):register()

local poll = timer.new(250)
poll.on_fire:connect(function()
  local s = perch.audio.spectrum()
  if s.live then bars:present({ levels = s.bands }) else bars:retire() end
end)
bars.on_visibility:connect(function(visible: boolean)
  if visible then poll:start() else poll:stop() end
end)

perch.audio.play

perch.audio.play(file: string): ()

Plays a WAV your package ships, by package-relative name ("sounds/ding.wav"), at the user’s master volume. WAV only, 4 MB per file. There is no path out of the package; a name that escapes it or names a missing file raises.

perch.system.timers.on_done:connect(function() perch.audio.play("sounds/ding.wav") end)

The mixer

Scope audio.mixer. Windows’ own volume mixer: one entry per application with a session on the default output. The scope both reads another application’s volume and moves it, so ask for it only when the mod is a mixer.

perch.audio.sessions(): { session_t }
perch.audio.set_session_volume(process_id: number, level: number): boolean
perch.audio.set_session_muted(process_id: number, muted: boolean): boolean

sessions is synchronous; sessions come and go as applications open and close, so re-read rather than caching PIDs. The setters take the process_id from a session and return false when that process has no session any more. level is a fraction 0..1, not the percent volume.set takes. A process with several sessions (Discord, browsers) has all of them set.

--!strict
type row_refs_t = { name: field_ref_t<string>, pid: field_ref_t<number>, level: field_ref_t<number>, muted: field_ref_t<boolean> }
type fields_t = { rows: list_ref_t<row_refs_t> }
type row_t = { name: string, pid: number, level: number, muted: boolean }

local mixer = widget.new { id = "mixer", label = "Mixer",
  fields = { rows = field.list { name = field.string(""), pid = field.number(0), level = field.number(0), muted = field.boolean(false) } } }

local function refresh()
  local rows: { row_t } = {}
  for i, s in ipairs(perch.audio.sessions()) do
    rows[i] = { name = s.name, pid = s.process_id, level = s.volume, muted = s.muted }
  end
  mixer:present({ rows = rows })
end

mixer:place(surface.page, function(f: fields_t): node_t
  return ui.list { items = f.rows, dir = ui.dir.col, gap = 6, max = 6,
    item = function(it: row_refs_t): node_t
      return ui.row { gap = 8, align = ui.align.center,
        ui.text { text = it.name, size = 12, flex = 1, style = { it.muted:is_true():set { opacity = 0.5 } } },
        ui.bar { value = it.level, w = 90, seek = true, knob = true,
          on_seek = function(ctx: input_context_t) perch.audio.set_session_volume(ctx.item.pid, ctx.frac or 0) end },
        ui.button { glyph = ui.glyph.speaker_slash,
          on_click = function(ctx: input_context_t) perch.audio.set_session_muted(ctx.item.pid, not ctx.item.muted) refresh() end },
      }
    end }
end):register()

perch.on_start:connect(refresh)

perch.audio.microphone.read

perch.audio.microphone.read(): microphone_t

Whether anything is capturing right now, and which applications are. Read only by design: Perch’s own microphone-live indicator is a trust surface, so no mod can mute the microphone, hide the indicator or fake its state. There is no signal; poll while visible.

local m = perch.audio.microphone.read()
if m.in_use then mic:present({ apps = table.concat(m.apps, ", ") }) else mic:retire() end

Types

export type volume_state_t = { level: number, muted: boolean }

level is 0..1, the UI-tapered scalar Windows shows, and 0 while muted.

export type spectrum_t = { bands: { number }, live: boolean }

bands has exactly the count you asked for; live is false when nothing is playing, and the bands then read silent.

export type session_t = { name: string, process_id: number, volume: number, muted: boolean, active: boolean }
FieldMeaning
namethe application’s name
process_idits PID, the handle the setters take
volumethat application’s own slider, 0..1
mutedmuted in the mixer
activemaking sound right now
export type microphone_t = { in_use: boolean, apps: { string } }

apps is empty when nothing is capturing.

esc
Type to search
navigate open