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)
endUnits, 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
| Constructor | Description |
|---|---|
newAudio | Creates the mixer a game plays sound through. |
Types
| Type | Kind | Description |
|---|---|---|
Audio | record | One output and the voices sounding on it. |
Clip | record | Reports one shared playable sound after its load. |
Config | type | Configures newAudio. |
Device | record | Names one physical audio device. |
Limit | type | Defines what one key allows. |
LoadOptions | type | Configures Audio.load. |
Microphone | record | An open recording device, pulled rather than pushed. |
MicrophoneConfig | type | Configures openMicrophone. |
PlayOptions | type | Configures Audio.play. |
Sound | struct | Attaches a sound to an entity. |
VoiceInfo | record | Describes one sounding voice for inspection. |
Functions
| Function | Kind | Description |
|---|---|---|
clipId | function | Returns the index of a clip path, assigning one the first time it is seen. |
clipPath | function | Returns the path a clip index represents. |
groupId | function | Returns the index of a group name, assigning one the first time it is seen. |
groupName | function | Returns the name a group index represents. |
install | function | Creates a mixer, installs it into a world, and returns it. |
of | function | Returns the mixer installed into a world. |
openMicrophone | function | Opens a microphone as interleaved native-endian 32-bit float samples. |
playbackDevices | function | Names the playback devices attached now. |
recordingDevices | function | Names the recording devices attached now. |
Values
| Value | Kind | Description |
|---|---|---|
SoundComponent | variable | The process-wide Sound component definition. |
Constructors#
newAudioconstructor#
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
| Name | Type | Description |
|---|---|---|
config | Config? | the settings, or nil for the defaults |
Returns
| Type | Description |
|---|---|
Audio | the mixer, which the caller has to |
Raises
when
maxVoicesfalls 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
endOne 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): nilSends 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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio |
Returns
| Type | Description |
|---|---|
nil |
destroy#
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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio |
Returns
| Type | Description |
|---|---|
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
| Name | Type | Description |
|---|---|---|
borrows self | Audio |
Returns
| Type | Description |
|---|---|
{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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
path | string | |
options | LoadOptions? |
Returns
| Type | Description |
|---|---|
Clip? | |
string? |
Raises
when the path is empty
clip#
Returns the clip an index names.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio | |
id | integer |
Returns
| Type | Description |
|---|---|
Clip? |
clips#
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
| Name | Type | Description |
|---|---|---|
borrows self | Audio |
Returns
| Type | Description |
|---|---|
{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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
path | string |
Returns
| Type | Description |
|---|---|
boolean | |
string? |
play#
play: function(exclusive self: Audio, clip: Clip?, options: PlayOptions?): integerPlays 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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
clip | Clip? | |
options | PlayOptions? |
Returns
| Type | Description |
|---|---|
integer |
stop#
stop: function(exclusive self: Audio, handle: integer, fadeOut: number?): nilStops a voice. A handle to one that has already ended does nothing.
Arguments
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer | |
fadeOut | number? |
Returns
| Type | Description |
|---|---|
nil |
stopAll#
stopAll: function(exclusive self: Audio, fadeOut: number?): nilStops 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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
fadeOut | number? |
Returns
| Type | Description |
|---|---|
nil |
playing#
playing: function(borrows self: Audio, handle: integer): booleanReports whether a handle still names a sounding voice.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio | |
handle | integer |
Returns
| Type | Description |
|---|---|
boolean |
paused#
paused: function(borrows self: Audio, handle: integer): booleanReports whether a handle names a paused voice.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio | |
handle | integer |
Returns
| Type | Description |
|---|---|
boolean |
pause#
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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer |
Returns
| Type | Description |
|---|---|
nil |
resume#
Lets a paused voice carry on.
Arguments
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer |
Returns
| Type | Description |
|---|---|
nil |
setGain#
setGain: function(exclusive self: Audio, handle: integer, gain: number): nilSets a voice's gain, before its group's and the master's.
Arguments
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer | |
gain | number |
Returns
| Type | Description |
|---|---|
nil |
setPitch#
setPitch: function(exclusive self: Audio, handle: integer, ratio: number): nilSets a voice's playback rate.
Arguments
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer | |
ratio | number |
Returns
| Type | Description |
|---|---|
nil |
setLoop#
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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer | |
loop | boolean |
Returns
| Type | Description |
|---|---|
nil |
looping#
looping: function(borrows self: Audio, handle: integer): booleanReports whether a voice repeats at the end.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio | |
handle | integer |
Returns
| Type | Description |
|---|---|
boolean |
seek#
seek: function(exclusive self: Audio, handle: integer, seconds: number): booleanMoves a voice's read position.
Arguments
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer | |
seconds | number |
Returns
| Type | Description |
|---|---|
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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer |
Returns
| Type | Description |
|---|---|
number? |
setPosition#
setPosition: function(exclusive self: Audio, handle: integer, x: number, y: number, z: number): nilPlaces 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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer | |
x | number | |
y | number | |
z | number |
Returns
| Type | Description |
|---|---|
nil |
setStereo#
setStereo: function(exclusive self: Audio, handle: integer, left: number, right: number): nilPins 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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer | |
left | number | |
right | number |
Returns
| Type | Description |
|---|---|
nil |
clearSpatial#
clearSpatial: function(exclusive self: Audio, handle: integer): nilReturns a voice to unpositioned mixing, out of either placement.
One call answers for both, because a voice holds one placement.
Arguments
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
handle | integer |
Returns
| Type | Description |
|---|---|
nil |
setMasterGain#
setMasterGain: function(exclusive self: Audio, gain: number): nilScales 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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
gain | number |
Returns
| Type | Description |
|---|---|
nil |
masterGain#
masterGain: function(borrows self: Audio): numberReturns master gain, whether or not mute holds it down.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio |
Returns
| Type | Description |
|---|---|
number |
setMuted#
setMuted: function(exclusive self: Audio, muted: boolean): nilSilences 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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
muted | boolean |
Returns
| Type | Description |
|---|---|
nil |
muted#
muted: function(borrows self: Audio): booleanReports whether master mute holds the output down.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio |
Returns
| Type | Description |
|---|---|
boolean |
sounding#
sounding: function(borrows self: Audio): integerReturns the number of voices sounding now, paused and fading ones included.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio |
Returns
| Type | Description |
|---|---|
integer |
maxVoices#
maxVoices: function(borrows self: Audio): integerReturns the configured ceiling on simultaneous voices.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio |
Returns
| Type | Description |
|---|---|
integer |
setGroupGain#
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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
name | string | |
gain | number |
Returns
| Type | Description |
|---|---|
nil |
groupGain#
Returns a group's gain.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio | |
name | string |
Returns
| Type | Description |
|---|---|
number |
setGroupMuted#
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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
name | string | |
muted | boolean |
Returns
| Type | Description |
|---|---|
nil |
groupMuted#
Reports whether mute applies to a group.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio | |
name | string |
Returns
| Type | Description |
|---|---|
boolean |
pauseGroup#
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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
name | string |
Returns
| Type | Description |
|---|---|
nil |
resumeGroup#
Lets a paused group carry on, and lets later joiners start sounding.
Arguments
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
name | string |
Returns
| Type | Description |
|---|---|
nil |
groupPaused#
Reports whether a group holds its current and future voices.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio | |
name | string |
Returns
| Type | Description |
|---|---|
boolean |
groups#
groups: function(borrows self: Audio): {string}Returns every group this mixer knows about, sorted.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio |
Returns
| Type | Description |
|---|---|
{string} |
stopGroup#
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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
name | string | |
fadeOut | number? |
Returns
| Type | Description |
|---|---|
nil |
setLimit#
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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
key | string | |
limit | Limit? |
Returns
| Type | Description |
|---|---|
nil |
limit#
Returns the limit assigned to a key.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio | |
key | string |
Returns
| Type | Description |
|---|---|
Limit? |
keyCount#
Returns how many voices a key holds now.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio | |
key | string |
Returns
| Type | Description |
|---|---|
integer |
keys#
keys: function(borrows self: Audio): {string}Returns every key with a limit or a counted voice, sorted.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Audio |
Returns
| Type | Description |
|---|---|
{string} |
voiceList#
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
| Name | Type | Description |
|---|---|---|
borrows self | Audio |
Returns
| Type | Description |
|---|---|
{VoiceInfo} |
update#
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
| Name | Type | Description |
|---|---|---|
exclusive self | Audio | |
dt | number? |
Returns
| Type | Description |
|---|---|
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: booleanRead-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
endReports one shared playable sound after its load.
Fields
path#
path: stringRead-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.
status#
status: stringRead-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.
duration#
duration: numberRead-only. Reports the audio duration in seconds, and zero when the input cannot say.
resident#
resident: booleanRead-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#
Names one physical audio device.
Re-exported from the backend contract so a game reaches it as tecs.audio.Device.
Fields
id#
id: integerRead-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: stringRead-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: integerRead-only. Reports the device's preferred frames per second, and zero when it will not say.
channels#
channels: integerRead-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
endAn 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
| Name | Type | Description |
|---|---|---|
borrows self | Microphone |
Returns
| Type | Description |
|---|---|
string? |
availableFrames#
availableFrames: function(borrows self: Microphone): integerRead-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
| Name | Type | Description |
|---|---|---|
borrows self | Microphone |
Returns
| Type | Description |
|---|---|
integer |
overruns#
overruns: function(borrows self: Microphone): integerRead-only. Reports the frames dropped because nothing read them in time, counted since the microphone opened. Zero once destroyed.
Arguments
| Name | Type | Description |
|---|---|---|
borrows self | Microphone |
Returns
| Type | Description |
|---|---|
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
| Name | Type | Description |
|---|---|---|
exclusive self | Microphone | |
maxFrames | integer? |
Returns
| Type | Description |
|---|---|
string? | |
string? |
readInto#
readInto: function(exclusive self: Microphone, out: {number}, maxFrames: integer?): integerRead-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
| Name | Type | Description |
|---|---|---|
exclusive self | Microphone | |
out | {number} | |
maxFrames | integer? |
Returns
| Type | Description |
|---|---|
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
| Name | Type | Description |
|---|---|---|
exclusive self | Microphone |
Returns
| Type | Description |
|---|---|
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
| Name | Type | Description |
|---|---|---|
exclusive self | Microphone |
Returns
| Type | Description |
|---|---|
boolean | |
string? |
destroy#
destroy: function(exclusive self: Microphone): nilRead-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
| Name | Type | Description |
|---|---|---|
exclusive self | Microphone |
Returns
| Type | Description |
|---|---|
nil |
Fields
frequency#
frequency: integerRead-only. Reports the frames per second read answers in. This is the requested value, not the device's own.
channels#
channels: integerRead-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
endAttaches 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
playing#
playing: booleanCaller-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.
loop#
loop: booleanCaller-writable. Repeats playback. Clearing it part way through lets the voice play out to its end rather than cutting it.
pitch#
pitch: numberCaller-writable. Sets the playback rate. One leaves it unchanged, while two plays an octave higher in half the time.
spatial#
spatial: booleanCaller-writable. Reads x, y and z when true, and mixes without a position when false.
y#
y: numberCaller-writable. Sets the position above the listener, which is the opposite sign from world Y.
voice#
voice: integerEngine-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
endDescribes one sounding voice for inspection.
Fields
applied#
applied: numberRead-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: numberRead-only. Reports the playback rate sent, so the value already includes whatever variance play drew.
owned#
owned: booleanRead-only. Reports whether a Sound component started the voice instead of play.
spatial#
spatial: booleanRead-only. Reports whether the voice carries a position. This stays false when stereo does not, because a voice holds one placement.
x#
x: numberRead-only. Reports the last horizontal position sent, positive right. It has no meaning unless spatial.
y#
y: numberRead-only. Reports the last vertical position sent, positive up. It has no meaning unless spatial.
z#
z: numberRead-only. Reports the last depth position sent, positive behind. It has no meaning unless spatial.
left#
left: numberRead-only. Reports the last left-speaker gain sent. It has no meaning unless stereo.
right#
right: numberRead-only. Reports the last right-speaker gain sent. It has no meaning unless stereo.
Functions#
clipIdfunction#
function clipId(path: string): integerReturns the index of a clip path, assigning one the first time it is seen.
Arguments
| Name | Type | Description |
|---|---|---|
path | string | the non-empty path, which is not checked against the filesystem |
Returns
| Type | Description |
|---|---|
integer | an index from one up, shared by every mixer in the process and meaningless in a file, which is why |
Raises
when the path is empty
clipPathfunction#
function clipPath(id: integer): string?Returns the path a clip index represents.
Arguments
| Name | Type | Description |
|---|---|---|
id | integer | the index, where zero is the index of no clip at all |
Returns
| Type | Description |
|---|---|
string? | the path, or nil for zero and for an index never handed out |
groupIdfunction#
function groupId(name: string): integerReturns the index of a group name, assigning one the first time it is seen.
Arguments
| Name | Type | Description |
|---|---|---|
name | string | the non-empty name, which needs no declaring: naming one here is all it takes for the group to exist |
Returns
| Type | Description |
|---|---|
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
| Name | Type | Description |
|---|---|---|
id | integer | the index, where zero is the index of no group at all |
Returns
| Type | Description |
|---|---|
string? | the name, or nil for zero and for an index never handed out |
installfunction#
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
| Name | Type | Description |
|---|---|---|
exclusive world | ecs.World | the world to install into, which expects one mixer |
config | Config? | the mixer settings, or nil for the defaults |
Returns
| Type | Description |
|---|---|
Audio | the new mixer, also available through |
Raises
when the world already has a mixer installed
offunction#
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
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world to inspect |
Returns
| Type | Description |
|---|---|
Audio? | the mixer, or nil before |
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
| Name | Type | Description |
|---|---|---|
config | MicrophoneConfig? | the settings, or nil to open the system's current default recording device at 48000 Hz in mono with a one-second buffer |
Returns
| Type | Description |
|---|---|
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
frequencyis not a positive integer, whenchannelsis not an integer from 1 to 32, or whenbufferFramesis not a positive integer
playbackDevicesfunction#
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
| Name | Type | Description |
|---|---|---|
devices | audiobackend.Devices? | the provider to ask, or nil for the platform's own |
Returns
| Type | Description |
|---|---|
{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#
Names the recording devices attached now.
Arguments
| Name | Type | Description |
|---|---|---|
devices | audiobackend.Devices? | the provider to ask, or nil for the platform's own |
Returns
| Type | Description |
|---|---|
{Device} | the devices, read as |
string? | the reason, when something went wrong |
Values#
SoundComponentvariable#
const SoundComponent: ecs.ComponentDefinition<Sound>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.