tecs.audio

Clips, voices, groups, keyed limits, and entity-owned sound.

Tecs decides what should sound and a backend makes the noise. Voice slots, group settings, admission limits and every handle a game holds live here; the backend under tecs.platform.audiobackend receives one array of commands per frame and hands back the voices it has finished with. A machine with no output device still gets a working object: available reports false, play answers zero, and nothing raises.

Playback#

load reads a file and returns one shared clip per path. Clips shorter than streamSeconds hold decoded samples that every voice reading them shares, and longer clips stream separately for each voice. LoadOptions.stream overrides that choice.

local mixer <const> = tecs.audio.newAudio()
local step <const> = assert(mixer:load("assets/sfx/step.ogg"))
mixer:setLimit("footstep", {voices = 3, cooldown = 0.05})

local voice <const> = mixer:play(step, {gain = 0.7, group = "sfx", key = "footstep", pitchVariance = 0.1})
if voice ~= 0 then
    mixer:stop(voice, 0.2)
end

Units, in one place, because none of them is in a signature. Time is seconds everywhere on this surface: a fade, a seek, a loop point, a cooldown, a clip's duration. Every fade is a duration to run for rather than a moment to finish at, so calling one twice restarts it. Gain is linear amplitude rather than decibels, where zero is silence and one is the sound as recorded, and the level a voice reaches the output at is its own gain times its group's gain times the master gain. Pitch is a playback rate rather than an interval, so two is an octave up and half as long. Position is the backend's frame rather than the world's: the listener sits at the origin, x is positive right, y is positive up, and z is positive behind.

A voice handle packs a slot with its generation, so a handle stays meaningful for exactly as long as its voice sounds and never starts naming the voice that takes the slot next. Holding a stale one is not an error and requires no release.

Groups and limits#

A group name controls gain, mute, pause, resume and stop, and its settings apply to voices that join later as well as to the ones sounding now. A key controls admission through a concurrent voice limit and a cooldown. One voice may carry both, and neither name has to know about the other. Reaching a limit drops the new voice rather than stealing an older one.

Entity-owned sound#

An entity can carry Sound instead of a game keeping a handle. The audio pass starts it, follows the writable playback fields, and stops it when the component or the entity disappears. Write through world:getMut.

Devices and recording#

playbackDevices and recordingDevices name what is attached now, and openMicrophone opens one for capture. A Microphone is pulled rather than pushed: the backend fills a bounded buffer from its own thread and read empties it on the frame thread, so nothing a device thread runs ever enters Nupp.

for _, device in ipairs(tecs.audio.recordingDevices()) do
    print(device.id, device.name, device.frequency, device.channels)
end

local microphone <const> = assert(tecs.audio.openMicrophone({channels = 1}))
local block <const> = assert(microphone:read())
microphone:destroy()

Listing devices opens no mixer and no microphone, and opening a microphone opens no mixer, so a game that only wants to name devices pays for nothing else.

The frame#

update is not a world system, and install deliberately does not add one. Reaping voices has to continue while a world is paused, so an application drives it from the iteration instead.

A native stream error permanently fails that output. The callback publishes a flag; the next update sets available false, records failureReason, discards queued starts and releases every active voice without replay. Destroy the mixer and create a new one to reopen an output. Starting without an output remains a separate silent fallback, not a device-loss event.

A failed microphone keeps already buffered frames. Once drained, read returns nil and the failure reason, while the value-only readInto raises that reason. failure reports loss even before the buffer drains. resume refuses a failed stream; destroy it and open a new microphone. Native capture supports one to eight channels. The public argument range remains one to 32 for compatibility; a valid request above eight returns an operational refusal from the native opener. Device enumeration is snapshot-only and provides no change notifications. Listing devices and observing failure of an active stream do not depend on notifications.

Module contents

Constructors

ConstructorDescription
newAudioCreates the mixer a game plays sound through.

Types

TypeKindDescription
AudiorecordOne output and the voices sounding on it.
CliprecordReports one shared playable sound after its load.
ConfigtypeConfigures newAudio.
DevicerecordNames one physical audio device.
LimittypeDefines what one key allows.
LoadOptionstypeConfigures Audio.load.
MicrophonerecordAn open recording device, pulled rather than pushed.
MicrophoneConfigtypeConfigures openMicrophone.
PlayOptionstypeConfigures Audio.play.
SoundstructAttaches a sound to an entity.
VoiceInforecordDescribes one sounding voice for inspection.

Functions

FunctionKindDescription
clipIdfunctionReturns the index of a clip path, assigning one the first time it is seen.
clipPathfunctionReturns the path a clip index represents.
groupIdfunctionReturns the index of a group name, assigning one the first time it is seen.
groupNamefunctionReturns the name a group index represents.
installfunctionCreates a mixer, installs it into a world, and returns it.
offunctionReturns the mixer installed into a world.
openMicrophonefunctionOpens a microphone as interleaved native-endian 32-bit float samples.
playbackDevicesfunctionNames the playback devices attached now.
recordingDevicesfunctionNames the recording devices attached now.

Values

ValueKindDescription
SoundComponentvariableThe process-wide Sound component definition.

Constructors#

newAudioconstructor#

function newAudio(config: Config?): Audio

Creates the mixer a game plays sound through.

Never raises for want of hardware. A machine with no sound card gets an object whose calls all succeed and produce nothing, because few games require an audio device while many test machines lack one.

Arguments

NameTypeDescription
configConfig?

the settings, or nil for the defaults

Returns

TypeDescription
Audio

the mixer, which the caller has to destroy

Raises

  • when maxVoices falls outside one to 65535

Types#

Audiorecord#

record Audio
    failureReason: string?
    available: boolean

    flush: function(exclusive self: Audio): nil
    destroy: function(exclusive self: Audio): nil
    decoders: function(borrows self: Audio): {string}
    load: function(exclusive self: Audio, path: string, options: LoadOptions?): (Clip?, string?)
    clip: function(borrows self: Audio, id: integer): Clip?
    clips: function(borrows self: Audio): {Clip}
    reload: function(exclusive self: Audio, path: string): (boolean, string?)
    play: function(exclusive self: Audio, clip: Clip?, options: PlayOptions?): integer
    stop: function(exclusive self: Audio, handle: integer, fadeOut: number?): nil
    stopAll: function(exclusive self: Audio, fadeOut: number?): nil
    playing: function(borrows self: Audio, handle: integer): boolean
    paused: function(borrows self: Audio, handle: integer): boolean
    pause: function(exclusive self: Audio, handle: integer): nil
    resume: function(exclusive self: Audio, handle: integer): nil
    setGain: function(exclusive self: Audio, handle: integer, gain: number): nil
    setPitch: function(exclusive self: Audio, handle: integer, ratio: number): nil
    setLoop: function(exclusive self: Audio, handle: integer, loop: boolean): nil
    looping: function(borrows self: Audio, handle: integer): boolean
    seek: function(exclusive self: Audio, handle: integer, seconds: number): boolean
    tell: function(exclusive self: Audio, handle: integer): number?
    setPosition: function(exclusive self: Audio, handle: integer, x: number, y: number, z: number): nil
    setStereo: function(exclusive self: Audio, handle: integer, left: number, right: number): nil
    clearSpatial: function(exclusive self: Audio, handle: integer): nil
    setMasterGain: function(exclusive self: Audio, gain: number): nil
    masterGain: function(borrows self: Audio): number
    setMuted: function(exclusive self: Audio, muted: boolean): nil
    muted: function(borrows self: Audio): boolean
    sounding: function(borrows self: Audio): integer
    maxVoices: function(borrows self: Audio): integer
    setGroupGain: function(exclusive self: Audio, name: string, gain: number): nil
    groupGain: function(borrows self: Audio, name: string): number
    setGroupMuted: function(exclusive self: Audio, name: string, muted: boolean): nil
    groupMuted: function(borrows self: Audio, name: string): boolean
    pauseGroup: function(exclusive self: Audio, name: string): nil
    resumeGroup: function(exclusive self: Audio, name: string): nil
    groupPaused: function(borrows self: Audio, name: string): boolean
    groups: function(borrows self: Audio): {string}
    stopGroup: function(exclusive self: Audio, name: string, fadeOut: number?): nil
    setLimit: function(exclusive self: Audio, key: string, limit: Limit?): nil
    limit: function(borrows self: Audio, key: string): Limit?
    keyCount: function(borrows self: Audio, key: string): integer
    keys: function(borrows self: Audio): {string}
    voiceList: function(borrows self: Audio): {VoiceInfo}
    update: function(exclusive self: Audio, dt: number?): integer
end

One output and the voices sounding on it.

Nothing here is thread safe and nothing here calls back. Every method runs on the thread that calls it, and the backend's own audio thread reaches Nupp only by leaving finished handles where update collects them.

Methods

flush#
flush: function(exclusive self: Audio): nil

Sends every queued command to the backend.

update and the audio pass both call this, so a game following the ordinary frame never needs it. Call it directly only to close the gap between a command and the buffer it reaches.

Arguments
NameTypeDescription
exclusive selfAudio
Returns
TypeDescription
nil
destroy#
destroy: function(exclusive self: Audio): nil

Stops everything and closes the output.

Stops every voice without a fade, moves every clip to "released", and removes this mixer from every world that installed it. There is no reopening: make a new instance instead.

Arguments
NameTypeDescription
exclusive selfAudio
Returns
TypeDescription
nil
decoders#
decoders: function(borrows self: Audio): {string}

Returns the decoder names this build linked.

What a build asked for and what it got are different questions, and this answers the second.

Arguments
NameTypeDescription
borrows selfAudio
Returns
TypeDescription
{string}
load#
load: function(exclusive self: Audio, path: string, options: LoadOptions?): (Clip?, string?)

Loads a sound and returns its cached clip.

Loading the same path twice returns the same clip: a clip is the file, and playing it twice is two voices reading one clip. options.stream overrides the duration threshold that otherwise decides whether it stays in memory.

Arguments
NameTypeDescription
exclusive selfAudio
pathstring
optionsLoadOptions?
Returns
TypeDescription
Clip?
string?
Raises
  • when the path is empty

clip#
clip: function(borrows self: Audio, id: integer): Clip?

Returns the clip an index names.

Arguments
NameTypeDescription
borrows selfAudio
idinteger
Returns
TypeDescription
Clip?
clips#
clips: function(borrows self: Audio): {Clip}

Returns every loaded clip in index order.

For introspection: it builds a list per call, so nothing on a frame's path should read it.

Arguments
NameTypeDescription
borrows selfAudio
Returns
TypeDescription
{Clip}
reload#
reload: function(exclusive self: Audio, path: string): (boolean, string?)

Re-reads a clip's file over the clip already loaded from it.

A clip's index is its path's, so an edited file comes back under the index every Sound row already carries and nothing in the world is touched. A streamed clip holds nothing to replace, so this reports success and the next voice to start reads what is on disk now.

Blocking, like every other reload: it is a debug operation, and answering before the file has been read would report a success that had not happened.

Arguments
NameTypeDescription
exclusive selfAudio
pathstring
Returns
TypeDescription
boolean
string?
play#
play: function(exclusive self: Audio, clip: Clip?, options: PlayOptions?): integer

Plays a clip and returns a handle, or zero when nothing started.

Zero means the clip is not loaded, loading failed, a key's limit or cooldown declined it, or every voice is busy. None of those is worth raising over: a sound that does not play is not a reason for a frame to stop.

A key's limit drops the new voice and never steals an older one, which is the same reasoning a full voice pool follows.

Arguments
NameTypeDescription
exclusive selfAudio
clipClip?
optionsPlayOptions?
Returns
TypeDescription
integer
stop#
stop: function(exclusive self: Audio, handle: integer, fadeOut: number?): nil

Stops a voice. A handle to one that has already ended does nothing.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
fadeOutnumber?
Returns
TypeDescription
nil
stopAll#
stopAll: function(exclusive self: Audio, fadeOut: number?): nil

Stops every voice, over fadeOut seconds when that is given.

Reaches every voice, whichever group it is in and whether a Sound component started it or play did. A row still asking to sound starts a fresh voice on the next audio pass, so this silences a world rather than keeping it silent.

Arguments
NameTypeDescription
exclusive selfAudio
fadeOutnumber?
Returns
TypeDescription
nil
playing#
playing: function(borrows self: Audio, handle: integer): boolean

Reports whether a handle still names a sounding voice.

Arguments
NameTypeDescription
borrows selfAudio
handleinteger
Returns
TypeDescription
boolean
paused#
paused: function(borrows self: Audio, handle: integer): boolean

Reports whether a handle names a paused voice.

Arguments
NameTypeDescription
borrows selfAudio
handleinteger
Returns
TypeDescription
boolean
pause#
pause: function(exclusive self: Audio, handle: integer): nil

Holds a voice where it is, keeping its slot until something resumes or stops it.

Neither this nor resume takes a fade, and that is a decision rather than an omission. A faded pause would be a ramp run from here, a command that takes effect later, lands on frame boundaries rather than the audio clock, and needs its own answer for what a stop or a group gain during the ramp means. A game wanting that builds it from tell, stop with a fade, and a later play with start and fadeIn.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
Returns
TypeDescription
nil
resume#
resume: function(exclusive self: Audio, handle: integer): nil

Lets a paused voice carry on.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
Returns
TypeDescription
nil
setGain#
setGain: function(exclusive self: Audio, handle: integer, gain: number): nil

Sets a voice's gain, before its group's and the master's.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
gainnumber
Returns
TypeDescription
nil
setPitch#
setPitch: function(exclusive self: Audio, handle: integer, ratio: number): nil

Sets a voice's playback rate.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
rationumber
Returns
TypeDescription
nil
setLoop#
setLoop: function(exclusive self: Audio, handle: integer, loop: boolean): nil

Changes whether a voice repeats, part way through.

Clearing this on a looping piece of music lets it play out to its end rather than cutting it, and setting it on a one-shot keeps it going. It reaches a sounding voice only: a stopped one takes its answer from the next play.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
loopboolean
Returns
TypeDescription
nil
looping#
looping: function(borrows self: Audio, handle: integer): boolean

Reports whether a voice repeats at the end.

Arguments
NameTypeDescription
borrows selfAudio
handleinteger
Returns
TypeDescription
boolean
seek#
seek: function(exclusive self: Audio, handle: integer, seconds: number): boolean

Moves a voice's read position.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
secondsnumber
Returns
TypeDescription
boolean
tell#
tell: function(exclusive self: Audio, handle: integer): number?

Returns a voice's read position in seconds.

A paused voice reports where it stopped, which with seek and a later play carrying start and fadeIn is what taking a sound back where it left off is built from.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
Returns
TypeDescription
number?
setPosition#
setPosition: function(exclusive self: Audio, handle: integer, x: number, y: number, z: number): nil

Places a voice in space.

Replaces a pan set by setStereo rather than combining with it. Positioning folds the input down to mono before placing it, so a stereo clip loses its stereo image when positioned.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
xnumber
ynumber
znumber
Returns
TypeDescription
nil
setStereo#
setStereo: function(exclusive self: Audio, handle: integer, left: number, right: number): nil

Pins a voice to the front pair of speakers at explicit gains.

A pan rather than a position, and usually what a game laid out on a plane wants: there is no listener to subtract and no distance model to argue with, only how much of this comes out of each side. Replaces a position set by setPosition rather than combining with it.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
leftnumber
rightnumber
Returns
TypeDescription
nil
clearSpatial#
clearSpatial: function(exclusive self: Audio, handle: integer): nil

Returns a voice to unpositioned mixing, out of either placement.

One call answers for both, because a voice holds one placement.

Arguments
NameTypeDescription
exclusive selfAudio
handleinteger
Returns
TypeDescription
nil
setMasterGain#
setMasterGain: function(exclusive self: Audio, gain: number): nil

Scales everything.

One number on the output, so this costs the same whether one voice is sounding or every voice is. Setting it while muted changes the level a later unmute returns to and nothing audible now, which is what a volume slider moved with the sound off should do.

Arguments
NameTypeDescription
exclusive selfAudio
gainnumber
Returns
TypeDescription
nil
masterGain#
masterGain: function(borrows self: Audio): number

Returns master gain, whether or not mute holds it down.

Arguments
NameTypeDescription
borrows selfAudio
Returns
TypeDescription
number
setMuted#
setMuted: function(exclusive self: Audio, muted: boolean): nil

Silences everything without discarding the master gain.

It does not fan out to the groups: groupMuted answers whether a group is silenced, and writing every group's bit here would overwrite the answers an unmute has to put back.

Arguments
NameTypeDescription
exclusive selfAudio
mutedboolean
Returns
TypeDescription
nil
muted#
muted: function(borrows self: Audio): boolean

Reports whether master mute holds the output down.

Arguments
NameTypeDescription
borrows selfAudio
Returns
TypeDescription
boolean
sounding#
sounding: function(borrows self: Audio): integer

Returns the number of voices sounding now, paused and fading ones included.

Arguments
NameTypeDescription
borrows selfAudio
Returns
TypeDescription
integer
maxVoices#
maxVoices: function(borrows self: Audio): integer

Returns the configured ceiling on simultaneous voices.

Arguments
NameTypeDescription
borrows selfAudio
Returns
TypeDescription
integer
setGroupGain#
setGroupGain: function(exclusive self: Audio, name: string, gain: number): nil

Scales every voice in a group, and every voice that joins it later.

Composed here rather than sent as one number for the group, because a per-group gain on the backend would overwrite what each voice asked for.

Arguments
NameTypeDescription
exclusive selfAudio
namestring
gainnumber
Returns
TypeDescription
nil
groupGain#
groupGain: function(borrows self: Audio, name: string): number

Returns a group's gain.

Arguments
NameTypeDescription
borrows selfAudio
namestring
Returns
TypeDescription
number
setGroupMuted#
setGroupMuted: function(exclusive self: Audio, name: string, muted: boolean): nil

Silences a group without discarding the level it was set to.

Every voice in the group contributes zero while this holds, and an unmute puts each one back at its own gain times groupGain.

Arguments
NameTypeDescription
exclusive selfAudio
namestring
mutedboolean
Returns
TypeDescription
nil
groupMuted#
groupMuted: function(borrows self: Audio, name: string): boolean

Reports whether mute applies to a group.

Arguments
NameTypeDescription
borrows selfAudio
namestring
Returns
TypeDescription
boolean
pauseGroup#
pauseGroup: function(exclusive self: Audio, name: string): nil

Holds every voice in a group, and every voice that joins it later.

The mixer records the pause as well as sending it, because a voice started into a paused group would otherwise be the one thing still heard.

Arguments
NameTypeDescription
exclusive selfAudio
namestring
Returns
TypeDescription
nil
resumeGroup#
resumeGroup: function(exclusive self: Audio, name: string): nil

Lets a paused group carry on, and lets later joiners start sounding.

Arguments
NameTypeDescription
exclusive selfAudio
namestring
Returns
TypeDescription
nil
groupPaused#
groupPaused: function(borrows self: Audio, name: string): boolean

Reports whether a group holds its current and future voices.

Arguments
NameTypeDescription
borrows selfAudio
namestring
Returns
TypeDescription
boolean
groups#
groups: function(borrows self: Audio): {string}

Returns every group this mixer knows about, sorted.

Arguments
NameTypeDescription
borrows selfAudio
Returns
TypeDescription
{string}
stopGroup#
stopGroup: function(exclusive self: Audio, name: string, fadeOut: number?): nil

Ends every voice in a group, over fadeOut seconds when that is given.

Stops the voices rather than the group: a gain, mute or pause set on the name survives, and anything joining afterwards starts under them.

Arguments
NameTypeDescription
exclusive selfAudio
namestring
fadeOutnumber?
Returns
TypeDescription
nil
setLimit#
setLimit: function(exclusive self: Audio, key: string, limit: Limit?): nil

Caps how many voices a key may hold and how often it may start one.

A key is not a group. A group says where a sound's gain comes from and what a pause reaches; a key says how many of one sound the mix will carry. The two are set independently on play, so "at most three footsteps at once, all of them in the effects group" is the ordinary case.

Reaching a limit drops the new voice: play returns zero and leaves every sounding voice alone.

Arguments
NameTypeDescription
exclusive selfAudio
keystring
limitLimit?
Returns
TypeDescription
nil
limit#
limit: function(borrows self: Audio, key: string): Limit?

Returns the limit assigned to a key.

Arguments
NameTypeDescription
borrows selfAudio
keystring
Returns
TypeDescription
Limit?
keyCount#
keyCount: function(borrows self: Audio, key: string): integer

Returns how many voices a key holds now.

Arguments
NameTypeDescription
borrows selfAudio
keystring
Returns
TypeDescription
integer
keys#
keys: function(borrows self: Audio): {string}

Returns every key with a limit or a counted voice, sorted.

Arguments
NameTypeDescription
borrows selfAudio
Returns
TypeDescription
{string}
voiceList#
voiceList: function(borrows self: Audio): {VoiceInfo}

Returns every sounding voice, paused and fading ones included.

For introspection, on the same terms as clips. The handles it reports are the ones playing and stop take, so a caller can act on these results.

Arguments
NameTypeDescription
borrows selfAudio
Returns
TypeDescription
{VoiceInfo}
update#
update: function(exclusive self: Audio, dt: number?): integer

Sends queued commands and reaps the voices the backend has finished with.

Call once per frame with the frame's step. The step advances cooldowns, and nothing else here needs time: a fade is the backend's to run, and a voice is over when the backend says so rather than when a clock here says it should be.

Not a world system, and install deliberately does not add one: reaping has to continue during a world pause, so an application drives this from the iteration instead.

Arguments
NameTypeDescription
exclusive selfAudio
dtnumber?
Returns
TypeDescription
integer

Fields

failureReason#
failureReason: string?

Read-only. Reports terminal output loss observed by update. Nil until a device fails. Loss clears voices, prevents playback and requires a new mixer; there is no automatic reopen or replay on the default device.

available#
available: boolean

Read-only. Reports whether an output opened and remains usable as of the last update. False also describes a device-free mixer.

Cliprecord#

record Clip
    path: string
    id: integer
    status: string
    error: string?
    duration: number
    resident: boolean
end

Reports one shared playable sound after its load.

Fields

path#
path: string

Read-only. Reports the path the clip was loaded from, exactly as given. This is a clip's identity, so two spellings of one file are two clips.

id#
id: integer

Read-only. Reports the index a Sound carries.

status#
status: string

Read-only. Reports "ready", "failed" or "released". These words reach an agent through the debug server's clip list, so they are a compatibility surface rather than identifiers this tree renames. "released" means the load succeeded and the owning mixer later returned its samples.

error#
error: string?

Read-only. Reports the reason when loading failed, and nil otherwise.

duration#
duration: number

Read-only. Reports the audio duration in seconds, and zero when the input cannot say.

resident#
resident: boolean

Read-only. Reports whether decoded audio stays in memory. False identifies a clip each voice reads from the file for itself.

Configtype#

type Config = {
    --- Caller-writable. Sets the sample frequency in frames per second, and
    --- defaults to 48000.
    frequency: integer?,

    --- Caller-writable. Sets the number of output channels, and defaults to
    --- two.
    channels: integer?,

    --- Caller-writable. Sets how many voices may sound at once, and defaults
    --- to 32.
    maxVoices: integer?,

    --- Caller-writable. Sets the duration in seconds past which a clip streams
    --- instead of staying resident, and defaults to ten.
    streamSeconds: number?,

    --- Caller-writable. Supplies the backend instead of opening the native
    --- one, which is what a device-free test uses.
    backend: audiobackend.Backend?
}

Configures newAudio.

Devicerecord#

record Device
    id: integer
    name: string
    frequency: integer
    channels: integer
end

Names one physical audio device.

Re-exported from the backend contract so a game reaches it as tecs.audio.Device.

Fields

id#
id: integer

Read-only. Reports the device's position in the listing that produced it, from one up. Zero is never assigned and means "the platform's default" wherever a device is selected, so an id is only meaningful for the listing it came from.

name#
name: string

Read-only. Reports the platform's display name for the device. This is the only durable way to name a device, because an id moves when a device is attached or removed.

frequency#
frequency: integer

Read-only. Reports the device's preferred frames per second, and zero when it will not say.

channels#
channels: integer

Read-only. Reports the device's preferred channels per frame, and zero when it will not say.

Limittype#

type Limit = {
    --- Caller-writable. Sets how many voices this key may hold at once. Zero
    --- or absent removes the ceiling.
    voices: integer?,

    --- Caller-writable. Sets the cooldown in seconds after a voice starts.
    --- Zero disables the cooldown.
    cooldown: number?
}

Defines what one key allows.

LoadOptionstype#

type LoadOptions = {
    --- Caller-writable. Forces streaming when true and residency when false.
    --- Left unset, the clip's duration decides against `streamSeconds`.
    stream: boolean?
}

Configures Audio.load.

Microphonerecord#

record Microphone
    frequency: integer
    channels: integer
    failure: function(borrows self: Microphone): string?
    availableFrames: function(borrows self: Microphone): integer
    overruns: function(borrows self: Microphone): integer
    read: function(exclusive self: Microphone, maxFrames: integer?): (string?, string?)
    readInto: function(exclusive self: Microphone, out: {number}, maxFrames: integer?): integer
    pause: function(exclusive self: Microphone): (boolean, string?)
    resume: function(exclusive self: Microphone): (boolean, string?)
    destroy: function(exclusive self: Microphone): nil
end

An open recording device, pulled rather than pushed.

Capture is running by the time one of these exists and the backend's own thread is filling a buffer behind it. Nothing arrives until read asks, and nothing that thread runs enters Nupp: a device thread is one the Lua virtual machine never created, so the frames cross by this side emptying a buffer rather than by anything calling in.

The buffer is bounded. When a game stops reading, capture drops the oldest frames and counts them in overruns, preserving the most recent audio without unbounded memory or latency growth.

Methods

failure#
failure: function(borrows self: Microphone): string?

Read-only. Reports terminal capture failure, or nil while the stream is healthy. Frames already buffered remain readable; after draining, read returns nil and this reason, and readInto raises it. A zero-frame read remains a no-op. Recovery requires closing and reopening the microphone.

Arguments
NameTypeDescription
borrows selfMicrophone
Returns
TypeDescription
string?
availableFrames#
availableFrames: function(borrows self: Microphone): integer

Read-only. Reports the complete sample frames ready to read without waiting, so a partly arrived frame is not counted. Zero once destroyed, rather than an error.

Arguments
NameTypeDescription
borrows selfMicrophone
Returns
TypeDescription
integer
overruns#
overruns: function(borrows self: Microphone): integer

Read-only. Reports the frames dropped because nothing read them in time, counted since the microphone opened. Zero once destroyed.

Arguments
NameTypeDescription
borrows selfMicrophone
Returns
TypeDescription
integer
read#
read: function(exclusive self: Microphone, maxFrames: integer?): (string?, string?)

Read-only. Pulls up to maxFrames complete frames as interleaved native-endian float32 samples, channels * 4 bytes per frame. Omitting maxFrames takes everything ready now, and a limit above what is ready takes what is ready rather than waiting. An empty string means nothing was ready and is not a failure. Returns nil and the reason for a destroyed microphone or a backend failure, and raises when maxFrames is not a non-negative integer.

Arguments
NameTypeDescription
exclusive selfMicrophone
maxFramesinteger?
Returns
TypeDescription
string?
string?
readInto#
readInto: function(exclusive self: Microphone, out: {number}, maxFrames: integer?): integer

Read-only. Pulls up to maxFrames complete frames into out as interleaved samples from index one, writing no more frames than out holds, and returns how many it wrote. This is the allocation-free form of read, for a game draining a microphone every frame.

Arguments
NameTypeDescription
exclusive selfMicrophone
out{number}
maxFramesinteger?
Returns
TypeDescription
integer
pause#
pause: function(exclusive self: Microphone): (boolean, string?)

Read-only. Stops the device filling the buffer, keeping whatever is already in it. Pausing one already paused succeeds; a destroyed microphone reports false with a reason.

Arguments
NameTypeDescription
exclusive selfMicrophone
Returns
TypeDescription
boolean
string?
resume#
resume: function(exclusive self: Microphone): (boolean, string?)

Read-only. Starts the device filling the buffer again after pause. A destroyed microphone reports false with a reason.

Arguments
NameTypeDescription
exclusive selfMicrophone
Returns
TypeDescription
boolean
string?
destroy#
destroy: function(exclusive self: Microphone): nil

Read-only. Stops capture and closes the recording device. Safe more than once. Whatever was captured and not yet read is discarded with the buffer, so a last read belongs before this rather than after.

Arguments
NameTypeDescription
exclusive selfMicrophone
Returns
TypeDescription
nil

Fields

frequency#
frequency: integer

Read-only. Reports the frames per second read answers in. This is the requested value, not the device's own.

channels#
channels: integer

Read-only. Reports the interleaved channels per frame read answers with. This is the requested value, not the device's own.

MicrophoneConfigtype#

type MicrophoneConfig = {
    --- Caller-writable. Selects a physical device by the `id` a
    --- [`Device`](tecs.audio.Device) from `recordingDevices` reported. Omitted
    --- or zero uses the system's current default. An id names a position in
    --- the listing that produced it, so a game keeping a choice across runs
    --- keeps `deviceName` instead.
    device: integer?,

    --- Caller-writable. Selects a physical device by the `name` a
    --- [`Device`](tecs.audio.Device) reported, and wins over `device`. This is
    --- the durable way to name a device, because a name survives a run and an
    --- id moves when something is attached or removed.
    deviceName: string?,

    --- Caller-writable. Sets the frames per second `read` answers in, and
    --- defaults to 48000. The backend converts the device's own rate, so this
    --- is what arrives rather than what the hardware runs at.
    frequency: integer?,

    --- Caller-writable. Sets the interleaved channels `read` answers with, and
    --- defaults to one. The backend folds or spreads the device's own channels
    --- to reach it. The native backend carries at most eight, so a count above
    --- that and inside the accepted range is refused with a reason rather than
    --- raised.
    channels: integer?,

    --- Caller-writable. Sets how many frames the capture holds before the
    --- oldest are dropped, and defaults to one second at `frequency`.
    bufferFrames: integer?,

    --- Caller-writable. Supplies the device provider instead of resolving the
    --- native one, which is what a device-free test uses.
    devices: audiobackend.Devices?
}

Configures openMicrophone.

PlayOptionstype#

type PlayOptions = {
    --- Caller-writable. Sets linear gain before group and master gain, and
    --- defaults to one.
    gain: number?,

    --- Caller-writable. Repeats playback until stopped, and defaults to false.
    loop: boolean?,

    --- Caller-writable. Sets the position in seconds a repeat returns to, so
    --- an intro can play once and the rest of it loop. Defaults to zero.
    loopStart: number?,

    --- Caller-writable. Sets where the first pass begins in seconds, and
    --- defaults to zero.
    start: number?,

    --- Caller-writable. Sets the fade-in duration in seconds, and defaults to
    --- zero.
    fadeIn: number?,

    --- Caller-writable. Sets the playback rate, and defaults to one.
    pitch: number?,

    --- Caller-writable. Sets the fraction of `pitch` varied for each new
    --- voice, so 0.1 spreads voices over plus or minus a tenth. Defaults to
    --- zero.
    pitchVariance: number?,

    --- Caller-writable. Selects the group this voice joins, and defaults to
    --- none.
    group: string?,

    --- Caller-writable. Selects the limit bucket this voice counts against,
    --- and defaults to none.
    key: string?,

    --- Caller-writable. Enables spatial positioning. See
    --- [`Sound`](tecs.audio.Sound) for the coordinate system.
    spatial: boolean?,

    --- Caller-writable. Sets the position right of the listener.
    x: number?,

    --- Caller-writable. Sets the position above the listener.
    y: number?,

    --- Caller-writable. Sets the position behind the listener.
    z: number?,

    --- Caller-writable. Pins the voice to the front pair of speakers, which is
    --- a pan rather than a position. Ignored when `spatial` is set, because
    --- the backend holds one placement per voice.
    stereo: boolean?,

    --- Caller-writable. Sets left-speaker gain on the same linear scale as
    --- `gain`, and defaults to one.
    left: number?,

    --- Caller-writable. Sets right-speaker gain on the same terms as `left`.
    right: number?
}

Configures Audio.play.

Read once, when the voice starts. Changing the record afterwards reaches nothing: use the handle setters instead. Nothing here is retained, so one record may be filled and reused for every play.

Soundstruct#

struct Sound
    clip: integer
    playing: boolean
    gain: number
    loop: boolean
    pitch: number
    spatial: boolean
    x: number
    y: number
    z: number
    group: integer
    voice: integer
end
@derive(nupp.derive.Debug, nupp.derive.Serde)

Attaches a sound to an entity.

Presence is the instruction: an entity carrying this with a loaded clip starts sounding on the next audio pass and stops when the component or the entity goes away. That is what makes sound an entity rather than a handle a game has to remember to release, and it is why despawning something mid-sound does the obvious thing.

The audio pass sends position to the backend and does nothing else with it. A caller feeding world coordinates has three jobs this component does not do for it: subtract whatever it decided is listening, choose a scale between world units and the backend's, and decide that a screen-space sound has no world position at all and leaves spatial false.

The pass follows playing, gain, loop, pitch and the position for as long as the voice sounds, so writing any of them is enough and nothing has to restart the voice. clip and group are deliberately not followed: both take a stop and a fresh start to change anyway, and following them would put a read and a compare on every row that never does. Moving a sound into another group is setting group and clearing voice.

Disabling an entity silences its sound and re-enabling starts it again.

Fields

clip#
clip: integer

Caller-writable. Selects a clip by its clipId index. Zero plays nothing.

playing#
playing: boolean

Caller-writable. Requests playback. This field gives an instruction rather than a report: clearing it stops the voice and setting it again starts one, including on a one-shot that has already run out.

gain#
gain: number

Caller-writable. Sets linear gain before group and master gain.

loop#
loop: boolean

Caller-writable. Repeats playback. Clearing it part way through lets the voice play out to its end rather than cutting it.

pitch#
pitch: number

Caller-writable. Sets the playback rate. One leaves it unchanged, while two plays an octave higher in half the time.

spatial#
spatial: boolean

Caller-writable. Reads x, y and z when true, and mixes without a position when false.

x#
x: number

Caller-writable. Sets the position right of the listener.

y#
y: number

Caller-writable. Sets the position above the listener, which is the opposite sign from world Y.

z#
z: number

Caller-writable. Sets the position behind the listener.

group#
group: integer

Caller-writable. Selects a group by its groupId index. Zero joins no group.

voice#
voice: integer

Engine-owned. Reports the voice the audio pass assigned: zero before it starts and negative once a one-shot has finished. Ordinary game code reads it and writes only zero, which is how it asks for the sound again.

VoiceInforecord#

record VoiceInfo
    handle: integer
    clip: string?
    gain: number
    applied: number
    pitch: number
    group: string?
    key: string?
    paused: boolean
    stopping: boolean
    owned: boolean
    loop: boolean
    spatial: boolean
    x: number
    y: number
    z: number
    stereo: boolean
    left: number
    right: number
end

Describes one sounding voice for inspection.

Fields

handle#
handle: integer

Read-only. Reports the handle playing and stop accept.

clip#
clip: string?

Read-only. Reports the clip path, or nil after release.

gain#
gain: number

Read-only. Reports the gain the voice requested, before group and master gain.

applied#
applied: number

Read-only. Reports the gain sent for the voice, group gain and group mute included. Master gain is not in it: that is one number on the output rather than something multiplied per voice.

pitch#
pitch: number

Read-only. Reports the playback rate sent, so the value already includes whatever variance play drew.

group#
group: string?

Read-only. Reports the group name, or nil for no group.

key#
key: string?

Read-only. Reports the limit bucket, or nil for no bucket.

paused#
paused: boolean

Read-only. Reports whether its own pause or its group holds it.

stopping#
stopping: boolean

Read-only. Reports whether a fade-out is stopping the voice.

owned#
owned: boolean

Read-only. Reports whether a Sound component started the voice instead of play.

loop#
loop: boolean

Read-only. Reports whether playback repeats at the end.

spatial#
spatial: boolean

Read-only. Reports whether the voice carries a position. This stays false when stereo does not, because a voice holds one placement.

x#
x: number

Read-only. Reports the last horizontal position sent, positive right. It has no meaning unless spatial.

y#
y: number

Read-only. Reports the last vertical position sent, positive up. It has no meaning unless spatial.

z#
z: number

Read-only. Reports the last depth position sent, positive behind. It has no meaning unless spatial.

stereo#
stereo: boolean

Read-only. Reports whether the voice is pinned to the front pair.

left#
left: number

Read-only. Reports the last left-speaker gain sent. It has no meaning unless stereo.

right#
right: number

Read-only. Reports the last right-speaker gain sent. It has no meaning unless stereo.

Functions#

clipIdfunction#

function clipId(path: string): integer

Returns the index of a clip path, assigning one the first time it is seen.

Arguments

NameTypeDescription
pathstring

the non-empty path, which is not checked against the filesystem

Returns

TypeDescription
integer

an index from one up, shared by every mixer in the process and meaningless in a file, which is why Sound serializes the path

Raises

  • when the path is empty

clipPathfunction#

function clipPath(id: integer): string?

Returns the path a clip index represents.

Arguments

NameTypeDescription
idinteger

the index, where zero is the index of no clip at all

Returns

TypeDescription
string?

the path, or nil for zero and for an index never handed out

groupIdfunction#

function groupId(name: string): integer

Returns the index of a group name, assigning one the first time it is seen.

Arguments

NameTypeDescription
namestring

the non-empty name, which needs no declaring: naming one here is all it takes for the group to exist

Returns

TypeDescription
integer

an index from one up, shared by every mixer in the process

Raises

  • when the name is empty

groupNamefunction#

function groupName(id: integer): string?

Returns the name a group index represents.

Arguments

NameTypeDescription
idinteger

the index, where zero is the index of no group at all

Returns

TypeDescription
string?

the name, or nil for zero and for an index never handed out

installfunction#

function install(exclusive world: ecs.World, config: Config?): Audio

Creates a mixer, installs it into a world, and returns it.

Adds the system that plays Sound components and the snapshot handler that carries master and group settings. Pitch variance uses the world's named tecs.audio random stream, so reseeding and snapshot restore also restart future pitch draws. Standalone mixers made with newAudio keep an independent Nupp generator.

update is not added here. Reaping voices is not world work: it has to continue during a world pause, so an application drives it from the iteration instead.

Arguments

NameTypeDescription
exclusive worldecs.World

the world to install into, which expects one mixer

configConfig?

the mixer settings, or nil for the defaults

Returns

TypeDescription
Audio

the new mixer, also available through of

Raises

  • when the world already has a mixer installed

offunction#

function of(borrows world: ecs.World): Audio?

Returns the mixer installed into a world.

What lets something holding only the world reach the mixer, which is what the debug tools have and what a game writing its own systems often has too.

Arguments

NameTypeDescription
borrows worldecs.World

the world to inspect

Returns

TypeDescription
Audio?

the mixer, or nil before install and again after destroy

openMicrophonefunction#

function openMicrophone(config: MicrophoneConfig?): Microphone?, string?

Opens a microphone as interleaved native-endian 32-bit float samples.

No callback is installed anywhere. The backend's own thread fills a bounded buffer and the game pulls completed frames from the frame thread with Microphone.read, because a device thread is one the Lua virtual machine never created and entering Nupp from it is undefined.

Arguments

NameTypeDescription
configMicrophoneConfig?

the settings, or nil to open the system's current default recording device at 48000 Hz in mono with a one-second buffer

Returns

TypeDescription
Microphone?

an open microphone, already recording: capture is running before this returns, so frames accumulate from here whether or not anything reads them. Nil on failure, with the reason beside it, and nothing is left open in that case.

string?

the reason, when the first return is nil

Raises

  • when frequency is not a positive integer, when channels is not an integer from 1 to 32, or when bufferFrames is not a positive integer

playbackDevicesfunction#

function playbackDevices(devices: audiobackend.Devices?): {Device}, string?

Names the playback devices attached now.

A snapshot rather than a subscription: devices come and go while a game runs, so an id held across a hotplug may name nothing. A Device name is what survives a run.

Arguments

NameTypeDescription
devicesaudiobackend.Devices?

the provider to ask, or nil for the platform's own

Returns

TypeDescription
{Device}

the devices, listed afresh each call and the caller's to keep. Empty rather than nil when nothing could be asked, so a caller that only iterates needs no nil check.

string?

the reason, when something went wrong. Nil on success, including for a machine that genuinely has no playback device.

recordingDevicesfunction#

function recordingDevices(devices: audiobackend.Devices?): {Device}, string?

Names the recording devices attached now.

Arguments

NameTypeDescription
devicesaudiobackend.Devices?

the provider to ask, or nil for the platform's own

Returns

TypeDescription
{Device}

the devices, read as playbackDevices reads its own

string?

the reason, when something went wrong

Values#

SoundComponentvariable#

The process-wide Sound component definition.

Its saved fields carry the clip path and the group name rather than their indices, because an index belongs to the run that handed it out and a file carrying one would name whatever the next run interned in its place. The voice is not saved: a snapshot records that an entity has a sound, not how far through it the backend had got, so it starts again.