Sound and music
WAV playback via the same SDL3 SoundPlayer as hitmarker.
Where files live. A name is resolved against your script’s folder first (exactly like
aqua.draw.image), then falls back to the shared ~/.config/nvidia/snd/. Prefer your own
folder: the shared one is what the hitmarker and killsound pickers enumerate, so anything
you drop there becomes a selectable hitmarker sound for the user. Sequential groups from the
menu (e.g. hit(01-03).wav) work as with hitmarker, and only resolve in the shared folder.
| API | Description |
|---|---|
aqua.sound.play(file [, volume=1 [, pitch=1 [, channel="script" [, startSeconds=0]]]]) |
Play once (retrigger clears queue). Final gain = volume × game volume cvar (same as hitmarker). pitch is frequency ratio (1 = normal, >1 higher/faster). startSeconds skips into the sample. |
aqua.sound.loop(file [, volume=1 [, pitch=1 [, channel="script" [, startSeconds=0]]]]) |
Same, but repeats until stop. Kept topped up by the engine, so the wrap is seamless. |
aqua.sound.gain(volume [, channel="script"]) |
Change a playing channel’s level without restarting it. Also scaled by the game volume cvar. |
aqua.sound.stop([channel="script"]) |
Stop / clear queue on channel (also ends a loop) |
aqua.sound.list([refresh=false]) |
Array of available bare names / group labels in the SHARED folder |
aqua.sound.dir() |
Absolute path of the shared snd folder |
Use loop + gain rather than re-triggering. Replaying a one-shot at its own loop point
clears the SDL stream, which is audible as a click however seamless the sample is - and it
pins the phase to the sample start instead of letting it run. Crossfading two textures is
then just two looping channels and a gain swap:
aqua.sound.loop("noise_a.wav", 0.8, 1.0, "script2")aqua.sound.loop("noise_b.wav", 0, 1.0, "script3") -- running, silent-- later, to hand over without either restarting:aqua.sound.gain(0, "script2")aqua.sound.gain(0.8, "script3")startSeconds was added 2026-08-19 alongside loop/gain. An older injected module
ignores extra arguments silently (luaL_opt* has no arity check), so a script that needs
them cannot detect the shortfall from the argument itself - guard on if aqua.sound.loop then
and treat absence as “module older than the script”.
Channels: "script" (default), "script2", "script3", "hit", "kill". Script channels are isolated from the hitmarker ones both ways. play clears its channel to retrigger, so two script sounds that must overlap (a click over a sustained tone, one-shot under a loop) have to be on different channels - that is what script2/script3 are for.
-- Full master volume, normal pitch, script channelaqua.sound.play("click.wav")
-- Half of master volume, slightly higher pitchaqua.sound.play("click.wav", 0.5, 1.1)
-- Explicit channelaqua.sound.play("kill.wav", 1.0, 1.0, "kill")aqua.sound.stop("script")
-- Start 4.2 s in (5th argument)aqua.sound.play("long.wav", 1.0, 1.0, "script", 4.2)
for _, name in ipairs(aqua.sound.list(true)) do print(name)endWAV only (no MP3). Game-thread only (call from on_draw / on_frame_stage_notify).
Music stream (aqua.sound.stream) - Ogg Vorbis
Section titled “Music stream (aqua.sound.stream) - Ogg Vorbis”A different shape from play: one track at a time, decoded incrementally (a
multi-minute song costs a small buffer, not its whole PCM), with a queryable position so a
script can sequence a playlist against it. The engine pumps it every frame - there is no
pump() to forget. Decoding is vendored stb_vorbis, so no system codec is required.
| API | Description |
|---|---|
aqua.sound.stream.play(path [, offset=0 [, length=0 [, xorKey=nil [, startSeconds=0]]]]) |
Start a track. length 0 = to end of file. startSeconds starts partway in - prefer it over play-then-seek (see below). Returns true if playback started. |
aqua.sound.stream.enqueue(path [, offset=0 [, length=0 [, xorKey=nil]]]) |
Queue the track to start the instant the current one runs out of samples |
aqua.sound.stream.clear_next() |
Drop the queued track |
aqua.sound.stream.serial() |
Increments on each rollover to an enqueued track |
aqua.sound.stream.stop() |
Stop and free the track (also drops the queued one) |
aqua.sound.stream.seek(seconds) |
Jump within the track; false if out of range |
aqua.sound.stream.position() |
Seconds the device has actually played (not decoded) |
aqua.sound.stream.duration() |
Seconds, 0 if the decoder cannot report a length |
aqua.sound.stream.is_playing() |
false once the track finishes draining |
aqua.sound.stream.gain(v) |
v x game volume cvar, same convention as play |
offset/length address a byte range inside a larger file, so a track can be played
straight out of a game archive with no extraction. xorKey is a raw byte string that
unmasks the payload as it is read, phased on the absolute file offset; pass nil for a plain .ogg.
Starting partway in: pass startSeconds to play rather than calling seek after it.
play primes the device queue before it returns, and a seek throws that away and primes it
again - both decodes land in the same frame, and it is already the frame paying for the read
and the decoder init. seek is for jumping around a track that is already playing.
Gapless playlists: enqueue is the only way to chain tracks without a hole. Waiting for
is_playing() to go false cannot work - by the time it reports false the device has already
gone silent, and opening the next track then costs a synchronous read plus decoder init. With
enqueue the decoder swaps inside the refill and keeps feeding the same device stream. Poll
serial() to notice the hand-off (it increments) and queue the following track.
if aqua.sound.stream.serial() ~= lastSerial then lastSerial = aqua.sound.stream.serial() aqua.sound.stream.enqueue(nextTrack()) -- keep one always queuedendA rate change between tracks cannot be seamless (SDL must flush the resampler to accept a new input format); same-rate tracks hand over with no gap at all.
The script that calls play owns the channel: stopping or unloading it closes the stream,
so a stopped script never leaves a track playing.
local KEY = "\234\058\196\161\154\168\020\243\072\176\215\035\157\232\255\241"
aqua.sound.stream.play("/path/to/streams/CO", trackOffset + 8068, trackLength, KEY)aqua.sound.stream.gain(0.6)
function on_draw() if not aqua.sound.stream.is_playing() then next_track() -- sequence the playlist off the real end of the track endendGame-thread only, like the rest of aqua.sound.