Skip to content

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 channel
aqua.sound.play("click.wav")
-- Half of master volume, slightly higher pitch
aqua.sound.play("click.wav", 0.5, 1.1)
-- Explicit channel
aqua.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)
end

WAV 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 queued
end

A 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
end
end

Game-thread only, like the rest of aqua.sound.