dot-audio decides what is heard, how loud, how many at once, and what happens
on a machine with no sound card.
Requires dot-core.
A sound is an id and a path
Never a loaded AudioStream. A dedicated server validates the whole catalogue
without a sound card and without the files, and a game delivered as a
dot-cloud pack is mounted at run time, so a
catalogue that preloads cannot describe delivered content at all.
validate() therefore checks everything except whether the file exists;
missing_files() is a separate question, asked by the client about to need
them.
It ships no audio, and a game with no files can still make a noise. DotAudioSynth bakes short sounds out of a sine sweep, a deterministic hash and an attack-decay envelope, and DotAudioSinkGodot.bank holds them behind the filesystem — the moment a real file exists at the path a def already names, that file wins and nobody edits a line:
var bank := DotAudioSynth.bank(catalogue, {
&"fire_rifle": DotAudioSynth.Voice.SHOT,
&"hit_marker": DotAudioSynth.Voice.BLIP,
})
(audio.sink as DotAudioSinkGodot).bank = bank
The grit comes from a hash rather than randf(), so the bank is byte-identical on every machine and every run, which makes it something a test can assert about rather than listen to.
Usage
var audio := DotAudioManager.new()
audio.catalogue = my_catalogue
add_child(audio)
audio.setup() # picks a sink by asking the driver's name
audio.listener_position = camera.global_position # once a frame; culling needs it
audio.play(&"ui_click")
audio.play_at(&"rifle", muzzle.global_position)
audio.play_music(&"combat", 1.5) # crossfade; asking for what is playing does nothing
audio.duck(&"radio") # reference-counted, so two ducks do not fight
A sound already playing can be reshaped without restarting it: audio.shape(handle, volume_scale, pitch_scale) takes the same linear scales as play(), applied on top of the def’s gain and the pitch rolled when the handle started, so an engine loop shaped every frame keeps both. It returns false once the voice has ended or been stolen, which is the cue to start it again, and audio.voice(handle) reports what a playing voice is set to.
Hand it a
DotRandomStream and a
shot’s pitch and variant are the same on the server and every client watching,
which is what makes an audio bug reproducible:
audio.roll_source = rng.stream(&"audio")
The engine reports a working sound card when there is none
In a headless run AudioServer.get_mix_rate() is 44100, the device list is
["Default"] and the output latency is 0.0. Only get_driver_name() says
"Dummy". A capability check built on any of the others passes on a machine
with no audio, and the symptom is silence for ever with nothing reporting a
problem.
That is why there is a DotAudioSink interface, and why DotAudioSinkNull is
not a mock: the real catalogue, culling, cooldowns, caps and music state machine
run above it, and only the lines that would create an AudioStreamPlayer are
replaced. It is also what lets a game that generates its own audio keep doing so.
Footsteps, ambience and one loudness for every game
DotAudioFootstepsgives the player, other players and bots a step per stride of distance travelled, so a body moved by the network steps at the rate it moves.track(body, opts)follows one body andkeep(bodies, opts)follows a whole group. It picks the surface from the shape the ray hit first (a collider’ssurfacemeta, asurface_woodgroup, a name likeFloor_Wood, or thedot_fps_surfacemeta dot-player-controller reads), andstepped(body, surface, loudness)fires for each step. When the body’s ownis_on_floor()is stale it asks the ground instead.DotAudioAmbienceplays a bed per map (set_map_bed()) and swaps it inside zones (add_zone(box, bed, priority)), crossfaded, so a game is never digital silence between events.DotAudioStockholds the stock footsteps and ambience beds (all CC0).DotAudioStock.add_to(catalogue)adds them as ordinary defs, a game’s own def with the same id wins, andids()lists every stock id for a whole-list check. They play from an addon pack too: a file with no imported output is decoded from its own bytes.- A loudness standard.
DotAudioLoudnessstates the target (TARGET_LUFS-17) and a true-peak ceiling of -1 dBTP.DotAudioManager.limit_masteris on by default and installs a master limiter whose sample ceiling is -2 dBFS.loudness_trim_dbis the whole-game nudge and is added to every voice’s gain, because a browser export runs no bus effects: the limiter does nothing there, but the per-voice levels and the trim still apply.
A volume slider is not decibels
Mapping a 0..1 slider onto volume_db, even scaled to -60..0, gives a control
where everything below about 0.9 is inaudible. Hearing is roughly logarithmic,
so the slider carries a linear amplitude and linear_to_db converts. Zero
is a muted bus rather than -60 dB, because a slider at the bottom should be
silence and not a number a later +3 dB turns back into sound.
DotAudioMixer is a DotConfig, so dot-ui generates the screen and dot-settings
persists it, with nothing here knowing either exists.
Three limits, which are three different problems
max_concurrent |
Twelve identical rifles half a millisecond apart is not twelve gunshots. It is one gunshot twelve times as loud, with comb filtering. Three sounds like a crowd. |
cooldown_ms |
A footstep triggered from a physics callback fires twice in a frame, and the second is inaudible and still costs a voice. |
max_distance |
A cull, not a curve. A sound at 400 metres attenuated to inaudible still costs a stream load, a voice and a position update every frame. |
The pool is fixed: the sink creates its players once and never frees them.
Create-play-queue_free has no ceiling, and a firefight is a hundred nodes
created and destroyed per second, which on the web is a visible hitch. When the
pool is full the lowest-priority voice is stolen, and if nothing is lower
the new sound is refused — a gunshot losing to a footstep because the footstep
started first is the failure mode of every fixed pool without priority.
