Quick navigation

Go to

Audio & communication

Resource audio

`open77_sound` plays an audio file your resource ships: spatial behaviour, server twins, ownership and lifetime, why files are never shared across resources, and how it differs from the game's own `Open77.sfx` events.

9 min read2,087 wordsMarkdown
On this page9 sections

open77_sound plays audio files bundled with a resource, with controls for volume and pitch. Use it for custom interface sounds, ambient audio, radios and positional emitters.

It is a different thing from Open77.sfx.*, and both stay. sfx plays the game's own Wwise events through the game's own mixer; it is the right tool for a gunshot, a door, a vehicle, a menu blip (Open77.sfx.play2d for that last one, since a UI click has no position). open77_sound plays your file, which Wwise has never heard of. Neither replaces the other, and nothing about Open77.sfx.* changed when this arrived.

One asymmetry is worth knowing before you choose: open77_sound has volume and pitch; Open77.sfx does not, and cannot. The engine's audio events carry no gain and no pitch field at all β€” the measurement is in effects.md. If you need to fade something or bend its pitch, it has to be your file.

Read What this is not before you build on it. It is a short list and every item on it is load-bearing.

Source: open77_sound.

The model

One hidden CEF surface, owned by the bundled open77_sound resource, runs the Web Audio graph for the whole session: decode, gain, pitch, looping and an HRTF panner per spatial sound. Your resource never sees it. You call an export, the service ships your file's bytes to that surface once, and it plays them.

your resource --exports--> open77_sound --page:send--> hidden CEF surface
   ships the file            owns the ids,             AudioContext
   names the sound           the caps, the             PannerNode (HRTF)
                             lifetime                  system audio out

Three consequences worth knowing up front:

  • The surface is created invisible, 16x16, at 1 fps β€” the lowest frame rate the WebUI host allows. visible = false parks the compositor entirely (WasHidden(true)), and the audio thread is clocked independently of it, so the page costs a browser process at idle and no composited frame.
  • Only the listener is per-frame-ish, and it is not per-frame: the camera transform is pushed at 20 Hz and only while something spatial is playing.
  • Everything is client-local and owned. A sound belongs to the resource that started it, on the client it started on. The server twins below are a wire that asks each client to play it, not a replicated audio entity.

Ship a file

Declare it in your manifest's files entry. That declaration is the permission: a file merely sitting in your resource directory cannot be played.

resource "cops"
version "1.0.0"
auto_start true

client_script "client/main.lua"
server_script "server/main.lua"

files { "sfx/*.ogg", "sfx/siren.mp3" }

Playable extensions: mp3, ogg, oga, opus, wav, flac, m4a, aac, webm. Anything else is refused with unsupported_audio_format rather than handed to a decoder that will not take it.

Play it

-- Flat, everywhere, no position: a UI beep or a phone in your own hand.
exports.open77_sound:play("phone", "sfx/ring.ogg", { volume = 0.7, loop = true })

-- In the world. This is the feature.
exports.open77_sound:play("boombox", "sfx/street.ogg", {
    position    = { x = -1590.0, y = 390.0, z = 25.0 },
    loop        = true,
    maxDistance = 35.0,
})

-- Following a body or a vehicle.
exports.open77_sound:play("siren", "sfx/siren.ogg", {
    entity      = vehicleEntityId,
    loop        = true,
    maxDistance = 80.0,
})

exports.open77_sound:setVolume("boombox", 0.3)
exports.open77_sound:setPosition("boombox", { x = -1600.0, y = 395.0, z = 25.0 })
exports.open77_sound:stop("phone")
Option Default Meaning
position β€” A world point. Its presence is what makes a sound spatial.
entity β€” An entity the sound follows. Mutually exclusive with position.
volume 1.0 0..1, your resource's own gain.
loop false Repeats until stopped, or until your resource stops.
pitch 1.0 0.25..4. Playback rate, so it changes speed and pitch together.
offset 0 Seconds into the clip to start at.
maxDistance 40 Silent at and beyond this distance.
refDistance 1 Full volume within this distance. Must be below maxDistance.

Playing an id that is already playing restarts it. A doorbell rung twice is one doorbell, not two overlapping ones. Use different ids when you want both.

The full export list is in Official resource exports.

The spatial behaviour

A spatial sound is a PannerNode with panningModel = "HRTF" β€” a head-related transfer function, not a left/right pan. That distinction is the whole point: an equal-power pan can only tell you a sound is to your left, while HRTF is what makes a boombox on the street behind you audibly behind you rather than merely quiet.

The listener is your camera, not your body. Open77.camera.view() is read at 20 Hz β€” and only while at least one spatial sound is playing, which is what keeps that read off the idle path; it costs six RTTI lookups and up to six ExecuteFunction calls on the game thread. Both the listener transform and the panner positions are ramped over 60 ms, so the image slides rather than stepping five times a second when you whip the camera.

The game is Z-up (X east, Y north, Z up) and Web Audio is Y-up with the listener facing βˆ’Z, so every position and direction crosses through one mapping:

audio.x =  game.x      right stays right
audio.y =  game.z      up stays up
audio.z = -game.y      north becomes "away"

Its determinant is +1, so handedness is preserved β€” which matters, because a mapping that flips it would put sounds behind you in front of you and nothing else would look wrong.

Distance uses the linear model, not the inverse one: gain falls from full at refDistance to exactly zero at maxDistance. "Audible within 35 m" therefore means what it says. The inverse model never quite reaches zero, which is more physical and less useful for a script.

Two things the listener does not carry. There is no roll: the client API gives position and forward, not the camera's up vector, so up is taken as the world's +Z. And there is no velocity, so there is no Doppler.

Server twins

A server resource names a file it ships and an audience. The audience is resolved on the server and the event goes to the players who could hear it β€” never broadcast for every client to receive and discard.

-- One player.
Open77.sound.play(source, "sfx/phone.ogg")

-- Everyone. One event, not one per player.
Open77.sound.broadcast("sfx/curfew.ogg", { volume = 0.4 })

-- Everyone within 40 m of a point, in routing bucket 3.
Open77.sound.play(
    { near = { x = -1590.0, y = 390.0, z = 25.0 }, radius = 40.0, bucket = 3 },
    "sfx/street.ogg",
    { id = "boombox", loop = true, position = { x = -1590.0, y = 390.0, z = 25.0 } })

-- Everyone within 25 m of a player, in that player's own bucket.
Open77.sound.play({ near = source, radius = 25.0 }, "sfx/shout.ogg")

Open77.sound.stop(source, "phone")
Open77.sound.setVolume(-1, "boombox", 0.2)
Open77.sound.stopAll()

{ near = ..., radius = ... } is handed straight to Open77.players.nearby, so its options are the same ones β€” bucket, limit, maxAgeMs, includeSelf β€” and so is its bucket rule: the anchor player's own bucket by default, bucket = <n> to name one, and bucket = false for every bucket. Two players in different routing buckets are not in the same world and never hear each other's boombox.

radius is required in that form. A near with no radius would silently reach the whole bucket, which is the mistake the shape exists to prevent.

id defaults to the file path, so the common case β€” one sound, one file β€” needs no id at all.

The client half must exist

The file is read out of the session's client resource image, so the resource that ships it needs at least one client_script. A server-only resource is never downloaded, and its audio is unreachable; the client says so on the local open77:soundFailed event and in the log rather than failing silently.

Ownership, limits and lifetime

Limit Value Why that number
Simultaneous sounds, per resource 8 So one resource cannot spend the session budget.
Simultaneous sounds, per session 24 24 concurrent HRTF panners is already a heavy mix.
File size 1 MiB The audio package keeps its own clip cap. The updated client's file-read ceiling is 3 MiB, but the 2 MiB WebUI message ceiling is unchanged; a larger Base64 result need not fit. See runtime quotas.
Decoded cache 64 MiB, LRU Decoded audio is 32-bit float per channel: a one-minute stereo clip is ~21 MiB no matter how small its file was.
Clip transport once per file A clip crosses on its first play and is replayed from the cache afterwards. preload(file) moves that cost off the moment the sound has to be heard.

Over a cap, play returns nil, "owner_sound_limit" or nil, "sound_limit". It refuses; it does not evict somebody else's sound to make room.

A sound cannot outlive the resource that started it. That is the failure mode this whole design is aimed at β€” a looping siren still wailing after its resource is gone, with nothing left that knows how to stop it. Three things catch it:

  • a reload is caught by a generation check, on the owner's very next call;
  • a stop is caught by a 250 ms owner sweep. It has to be the sweep: the client host dispatches onClientResourceStop to the stopping instance only, so open77_sound never hears about anyone else's stop. A quarter second of siren is the bounded worst case;
  • open77_sound itself stopping takes the surface with it, and the AudioContext and every source node hanging off it go with the surface.

A server resource stopping sends stopOwner to every client automatically.

Cross-resource files: no

A resource can only play a file it ships itself. This is not a policy choice; it is the shape of the host, in two independent places:

  • Open77.resource.readFile / LoadResourceFile resolve under the calling resource's root and refuse a different resource by name (cross_resource_read_denied);
  • a bundled WebUI surface's virtual files come from its owning resource's root and declared allowed-file list; requests into another resource's virtual origin are refused. The updated external-content policy allows ordinary web hosts, not cross-resource virtual-file reads.

Those two together mean the bundled service could not serve cops/sfx/siren.mp3 to its own page at all. The one door that makes the service possible is a native Open77.resource.readPackedFile(resource, path) gated by the resources.files.read permission β€” which only open77_sound holds, and which reads declared files only. open77_sound never takes the owner from an argument: on the export path it is GetInvokingResource(), and on the server path it is stamped by the host from the calling server resource's name, with open77:sound: reserved as a client-event prefix so no resource can write that field itself.

The alternative β€” letting any resource read any other's assets β€” buys nothing a resource cannot get by shipping its own copy, and costs the guarantee that a resource's files are its own.

What this is not

Plainly, because each of these will otherwise be discovered the hard way:

  • It is not Wwise, and it is not in the game's mix. It is a second, parallel audio path that ends at the same speakers. Nothing it plays ducks gunfire, dialogue or music, and nothing the game plays ducks it.
  • It does not follow the player's in-game audio sliders. The game's master, music, SFX and dialogue volumes do not reach it. If your server should respect them, wire that yourself: read a setting and call setVolume.
  • It does not occlude. A sound inside a building is heard through the wall at full distance-attenuated volume. There is no raycast, no material, no reverb β€” distance and direction, and nothing else.
  • There is no Doppler and no roll. No velocity is fed to the listener or the sources, and the camera's up vector is not available to the client API, so the world's +Z is used instead.
  • It is not replicated. Two clients told to play the same sound are two independent playbacks that will drift. Anything that must be sample-accurate across clients is out of scope.
  • It is one shared surface. Every resource's audio runs in one AudioContext; a resource cannot be given its own device, its own master gain or its own output.
  • Latency is a browser's latency, not an engine's. A clip's first play pays transport and decode; preload exists precisely because that is real.

A native Wwise external-source path would fix most of this list. It is a later upgrade and it is not what this is.

Reasons

Every refusal is a stable snake_case token.

Token Meaning
invalid_sound_id Empty, over 240 characters, or outside [%w_:-./].
invalid_file Empty, over 240 characters, or containing .., a leading / or a :.
unsupported_audio_format The extension is not one the browser will decode.
file_not_declared The file is not in that resource's files manifest entry.
clip_too_large Over 1 MiB.
file_unreadable Declared, but the file could not be read from disk.
resource_not_found The named resource is not in this client's resource set.
owner_sound_limit / sound_limit Caps above.
sound_not_found No live sound of yours holds that id.
sound_not_spatial setPosition on a sound started with neither position nor entity.
position_and_entity Both were given; pick one.
entity_not_found The entity has no readable position on this client.
surface_unavailable The page is not up yet, or open77_sound is not running.
audio_blocked The browser refused to start audio. See below.
export_call_required Called other than through an export, so there is no owner.
invalid_sound_target / invalid_sound_radius Server side: a bad audience.
no_audience Server side: the audience resolved to nobody.
reserved_sound_event Server side: open77:sound: is not yours to emit.

audio_blocked deserves a note. Chromium gates unattended audio behind a user gesture, per document, and this surface is invisible and receives no input β€” so it can never earn one. The WebUI host therefore runs with autoplay-policy=no-user-gesture-required: every document in it is a file a resource declared, served from disk over a per-surface origin under default-src 'self' with connect-src 'none', so there is no drive-by web content the gate was protecting anyone from. The page still calls resume() and still reports audio_blocked if that ever changes, so the failure stays named instead of going silent.

// For agents & LLMsthis page as Markdownllms.txtllms-full.txt

// Guides Β· Lua API

Search the docs

1,905 guides and functions indexed

Try