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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
typeConfig={--- 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?}
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.
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.
typeLimit={--- 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?}
typeLoadOptions={--- Caller-writable. Forces streaming when true and residency when false.--- Left unset, the clip's duration decides against `streamSeconds`.stream:boolean?}
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.
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.
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.
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.
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.
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.
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.
typeMicrophoneConfig={--- 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?}
typePlayOptions={--- 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.
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.
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.
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.
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.
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.
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.
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.
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
Type
Condition
string
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
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 Devicename is what survives a run.
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.
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.