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.
| Scope | Installs |
|---|---|
volume.control | perch.audio.volume.up(), .down(), .set(level) |
system.transients | perch.audio.volume.on_change |
media | perch.audio.spectrum(bands?) |
audio.play | perch.audio.play(file) |
audio.mixer | perch.audio.sessions(), .set_session_volume(pid, level), .set_session_muted(pid, muted) |
audio.microphone | perch.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 }
| Field | Meaning |
|---|---|
name | the application’s name |
process_id | its PID, the handle the setters take |
volume | that application’s own slider, 0..1 |
muted | muted in the mixer |
active | making sound right now |
export type microphone_t = { in_use: boolean, apps: { string } }
apps is empty when nothing is capturing.