Audio#

Sound, over Unity's AudioSource.

import { audio } from "onejs-unity/audio"

const blip = await audio.load("sfx/blip.wav")
blip.play({ volume: 0.7 })

const theme = await audio.load("music/theme.wav")
const voice = theme.loop({ volume: 0.4 })
voice.stop()

Loading crosses into C# once and hands back a handle. Playing is one call with plain numbers. Nothing calls back into JavaScript while a sound is playing, so the cost is per sound started rather than per frame.

Why not WebAudio#

WebAudio exists only in a browser, so a game built on it could never leave the web. This runs on AudioSource underneath, which behaves the same on WebGL and on QuickJS, so a game that makes noise in the Editor makes noise everywhere you ship it.

Loading#

audio.load(url) takes a URL or a path, not a bare asset name.

That is worth pausing on, because it differs from loadImage and from oj's loadTexture, both of which take a bare name and resolve it for you. audio.load is deliberately not a variant of anything: it takes a URL, and you say which one.

await audio.load("sfx/blip.wav")                     // relative to the working directory
await audio.load("https://example.com/theme.wav")    // remote
await audio.load(getAssetPath("sfx/blip.wav"))       // resolved explicitly

With oj that means audio.load(assetUrl("pop.wav")), with assetUrl at the call site.

Loading is asynchronous everywhere and rejects with the underlying error if the fetch or the decode fails.

Format matters more than you would expect#

The file extension picks the decoder. There is no byte sniffing, because the format has to be declared before the download starts, and on WebGL the browser has to be told what it is decoding.

ExtensionDecoder
.wavWAV
.oggOgg Vorbis
.mp3MPEG
.aiff, .aifAIFF
anything elseUnknown

An unrecognised extension is not a hard error. It asks for an unknown format, which works on desktop, where the decoder inspects the bytes, and fails on WebGL, where it cannot. That is the shape of bug that passes every test in the Editor and ships silent.

Prefer .wav for anything that will run in a browser. Every browser decodes it, and producing one needs no encoder. The files are larger, which for short effects is a rounding error.

.ogg and .mp3 are much smaller and are the right answer for music, but on WebGL the decode is the browser's rather than Unity's, so support is whatever that browser supports. Ogg Vorbis in particular has a patchy history in Safari, so a game whose only music track is an .ogg can be silent for a real share of players. If you ship compressed audio to the web, test it in Safari specifically, or ship a .wav fallback.

A query string is ignored when picking the decoder, so a cache-busted theme.wav?v=3 still resolves to WAV.

Playing#

const voice = sound.play({ volume: 0.7, pitch: 1.2 })
const music = sound.loop({ volume: 0.4 })

play() is a one-shot; loop() runs until stopped. Both return a Voice.

Option
volume0 to 1, clamped. Defaults to 1
pitchPlayback rate; 2 is an octave up. Clamped to 0.01 to 3. Defaults to 1

Randomising pitch slightly on a repeated effect is the cheapest way to stop it sounding mechanical:

step.play({ pitch: 0.95 + Math.random() * 0.1 })

Voices#

On a Voice
playingWhether it is still going
stop(), pause(), resume()
setVolume(v), setPitch(p)Change it mid-flight

A voice is safe to hold onto. It identifies one playing sound rather than a slot, so calling stop() on a voice that finished long ago cannot silence some later, unrelated sound.

playing asks rather than remembers, and the reason is the next section.

Voices are a pool, and a busy pool steals#

There are 24 voices. Starting a sound takes a free one; when none is free, it steals the oldest one-shot. Music is never stolen: a looping voice is left alone.

Two consequences worth designing around:

  • A one-shot can be cut off mid-play when a lot is happening at once. That is usually what you want, since the alternative is new sounds not playing at all.
  • If every voice is looping, a new sound does not play. You get back a voice that reports playing: false and whose methods do nothing. Nothing throws and nothing is logged. If sounds stop starting, count your loops.

Keep looping voices to the few you actually mean: music, ambience, an engine note. audio.activeVoices and audio.voices tell you how close you are.

Global control#

audio.stopAll()               // everything, in one call
audio.setMasterVolume(0.5)    // 0 to 1, across every sound
audio.getMasterVolume()
audio.setPaused(true)         // silence without losing where each sound was
audio.voices                  // 24
audio.activeVoices            // how many are going right now

setPaused is what a pause menu wants: stopAll ends everything and music restarts from the top afterwards, while setPaused resumes where it left off.

Unloading#

sound.unload()

Frees the clip. Anything still playing it stops first, which matters: a source left holding a destroyed clip logs an error on the next frame.

Sounds are not garbage collected for you, so unload anything level-specific when the level ends. Short effects reused throughout a game are fine to load once and keep.

With React#

Load in an effect, keep the handle in a ref, and unload on the way out:

import { useEffect, useRef } from "react"
import { audio } from "onejs-unity/audio"

function Game() {
    const blip = useRef(null)

    useEffect(() => {
        let live = true
        audio.load("sfx/blip.wav").then((sound) => {
            if (live) blip.current = sound
            else sound.unload()
        })
        return () => {
            live = false
            blip.current?.unload()
        }
    }, [])

    return <Button text="Beep" onClick={() => blip.current?.play()} />
}

The live flag matters because a component can unmount while the load is still in flight, and a sound that arrives after that would otherwise leak.

Notes#

A listener is created if your scene has none. UI-only scenes often have no camera, and nothing is audible without an AudioListener, so one is added alongside the voice pool. If your scene already has a listener, yours is used.

Sound is 2D by default. A UI game has no listener position for a 3D pan to be relative to, so spatialising would only make sounds quieter for no reason.

WebGL needs OneJS 3.2.1 or later. Earlier versions never settled audio.load in a web build: the promise neither resolved nor rejected, so the await simply never returned and nothing was logged. See Building & Deployment.

Preserve UnityEngine.AudioModule in a link.xml for IL2CPP and AOT targets. Nothing in your C# references it, so the stripper can remove it, and the failure is loud:

[oj] audio is unavailable: OneJS.Audio.AudioBridge was not found.

See Code Stripping.