Audio
Import a sound file, then either attach an AudioSource to an object or call api.playSound from a script. A sound can be 3D, heard from an object as it moves, and a script can make sound from code instead of a file. Sounds ride inside the project and inside the exported game, so a shipped game.html carries its own audio and needs no server.
Importing a sound
Drop an audio file onto the editor, or use + Import. Accepted: .opus, .ogg, .mp3, .wav, .m4a, .aac, .flac - whatever the browser can decode - and .mid / .midi, which it cannot (see MIDI).
The id is the file name without its extension: music.opus imports as music. Re-importing the same name replaces that sound, so an edited file updates every AudioSource pointing at it rather than forking a second copy.
Imported sounds live under Audio in the Asset Browser. Double-click one to hear it; selecting one shows its format and size, a play button, and whether it is a recording or a score - and, for a MIDI, the performance controls with a play button to audition them. Drag one onto an AudioSource's clip field to set it.
The bytes are stored encoded, exactly as imported, and decoded when the game loads. Decoding wants the host's sample rate, so a project that baked decoded samples at your machine's rate would resample on everyone else's - the same reason textures compress at load rather than at import.
Which format to use
Opus (.opus, Ogg Opus, audio/ogg; codecs=opus) is the one to pick. It sounds better than the others at the same size, its header is a few dozen bytes (an .ogg Vorbis header is about 4 KB, which is most of a short click), and its start and end are trimmed exactly. 96 kbps VBR is plenty for music. Convert with FFmpeg: ffmpeg -i in.wav -c:a libopus -b:a 96k -vbr on out.opus.
Every browser that runs Awaken decodes it, because Awaken already needs WebGPU: Chrome and Edge, Firefox, and Safari 26 and later. Safari 27 on macOS and in the iOS 27 Simulator both decoded Opus music and a 39 ms click to the right length and channel count. Ogg Vorbis did not decode on iOS Safari, so an .ogg Vorbis file is silent on an iPhone; use Opus, or AAC (.m4a), which also decodes everywhere.
An .ogg file can hold either Opus or Vorbis. The editor reads the file's first page, so an Opus file named .ogg is still kept as Opus, and a project folder writes it out as .opus.
A smaller file is a smaller download, not less memory: a sound is decoded whole before it plays, whatever it was encoded as.
MIDI
A .mid is a score, not a recording: a list of which note starts when, on which instrument, and how hard. That makes it thousands of times smaller than audio of the same music. The soundtrack this was built for is 18 minutes of solo piano in 17 KB - the same piece encoded as .ogg runs to tens of megabytes, which is not something you put in a game that is otherwise 600 KB.
Nothing decodes a score, so the engine performs it: Awaken embeds a General MIDI synthesiser (webaudio-tinysynth, Apache-2.0, about 11 KB gzipped) that builds every note out of oscillators. All 128 GM instruments and the drum channel work, along with program change, pitch bend, channel volume, pan, expression and the sustain pedal.
Import and use a .mid exactly like a recording - the AudioSource clip picker and api.playSound do not care which kind it is.
What to expect:
- It is synthesised, not sampled. There is no instrument bank, which is the point: a soundfont good enough to sound real is megabytes and would undo the saving. Expect a clean, synthetic tone rather than a recorded piano. Solo and small ensembles fare best; big orchestral scores are where the gap shows most.
- One MIDI at a time. A second one starting replaces the first. Recordings are unaffected - any number of those play at once, including over a MIDI.
- Nothing is decoded ahead, so length costs nothing. An 18-minute piece uses no more memory than a 20-second one, unlike a recording, which is decoded whole into RAM before it plays.
If a MIDI matters more than the bytes, render it to .ogg with a soundfont you like and import that instead. Both paths work; this is a trade, not a limitation.
Changing the performance
A recording is a fixed performance. A score is not - the engine is still deciding how to play it when the game starts, so these cost nothing: no second file, no re-encode.
| Control | Range | What it does |
|---|---|---|
tempo | multiplier, default 1 | Speed. 2 is twice as fast, 0.5 half. Pitch is unaffected - this conducts the score rather than speeding up a tape. A score that changes tempo on its own still does, in proportion. |
transpose | semitones, default 0 | Shift every melodic note. 12 is an octave up, -5 a fourth down. Percussion is left alone: a note number on the drum channel picks which drum, not a pitch. Notes pushed past the end of the keyboard stop at the edge rather than wrapping. |
instrument | GM program 0–127, or -1 | Force every melodic channel onto one instrument whatever the score asks for. 10 music box, 12 marimba, 19 church organ, 46 harp, 52 choir aahs, 73 flute. -1 plays the written instruments. |
timbre | rich / chiptune | How each note is built. rich uses two or more oscillators; chiptune uses one - thinner, cheaper, and the right answer for a game that wants to sound like 1988 hardware. |
reverb | 0–1, default 0.3 | Room size. 0 is dry and close. |
Where they live
A sound carries its own performance, set in its asset inspector (Assets → Audio → select it). Tune the instrument, key, speed and timbre once, with a play button right there to hear each change, and every AudioSource playing that sound gets it - as does api.playSound("theme") with no options at all. The instrument picker lists all 128 General MIDI programs by name, so it is searchable rather than a number you have to look up.
Everything but Tempo restarts the audition when you change it. Tempo does not, because the synthesiser can change speed mid-performance - which is the same reason setSoundTempo exists.
An AudioSource overrides it per object: tick overrideMidi and its own four fields apply instead. Leave it off (the default) and the object plays the sound the way its asset says to. A script overrides field by field, so asking for a different tempo keeps the instrument the asset was tuned to.
asset profile → AudioSource (overrideMidi) → api.playSound opts.midiThey are fields on the AudioSource for authoring, and opts.midi on api.playSound for scripts:
ts
api.playSound("theme", { loop: true, midi: { instrument: 10, transpose: -12, tempo: 0.8 } });Tempo can change while the music plays, which is the one that earns its keep - the same theme tightens as the danger rises, on the bar it was already on, with no crossfade and no second file:
ts
onUpdate(api: ScriptApi) {
const danger = this.enemiesNearby / 10;
api.setSoundTempo(this.music, 1 + danger * 0.4); // 1.0 calm → 1.4 frantic
}The other four are applied when the sound starts, so changing one means playing it again.
Playing a sound
The AudioSource component
Add AudioSource from the Inspector's + Add Component menu.
| Field | Type | Default | Meaning |
|---|---|---|---|
clip | asset ref | (none) | Which sound, by id. The picker lists what the project has imported. |
volume | number | 1 | Linear gain. 1 is the clip's own recorded level. |
spatial | bool | false | 3D: heard from this object, quieter and duller with distance, following it as it moves. Off plays the same everywhere. |
minDistance | number | 2 | Spatial only. Metres within which it plays at full volume. |
maxDistance | number | 80 | Spatial only. Metres at which it has faded out completely. |
pitch | number | 1 | Playback pitch. 2 is an octave up and twice as fast, 0.5 an octave down. |
loop | bool | false | Repeat until the scene ends. |
playOnStart | bool | true | Play automatically the moment Play begins. |
overrideMidi | bool | false | MIDI only. Off: play the sound's own performance. On: this object differs. |
tempo | number | 1 | MIDI only, and only with overrideMidi on. |
transpose | number | 0 | MIDI only. Semitones. |
instrument | number | -1 | MIDI only. GM program, or -1 for as written. |
timbre | enum | rich | MIDI only. rich or chiptune. |
The last four appear only when overrideMidi is on. All five are ignored for a recording, and cannot be hidden for one: a component only sees its own fields and cannot look the clip up to find out what kind of sound it is.
When Play starts, the runtime walks every object with both a Transform and an enabled AudioSource, and plays the clip of each one whose playOnStart is set. That is the component's whole lifecycle: it fires at start, and there is no built-in trigger to fire it again. For a sound driven by gameplay, turn playOnStart off and call api.playSound from a script.

Background music
Attach an AudioSource to whatever outlives the scene - the player object is the usual choice - set the clip, and turn loop on. Leave spatial off: music should not move when the camera turns, so the object's Transform makes no difference to what you hear, and attaching it to the player is about lifetime, not about place.
Pressing Stop, and loading another scene, both silence it.
From a script
ts
export default class Jukebox implements ScriptBehavior {
private music: number | null = null;
onStart(api: ScriptApi) {
this.music = api.playSound("theme", { volume: 0.6, loop: true });
}
onUpdate(api: ScriptApi) {
if (api.input.isDown("m") && this.music !== null) { // mute
api.stopSound(this.music);
this.music = null;
}
}
}playSound(clip, opts?) returns a handle, or null if the project has no sound by that id. stopSound(handle) ends that one sound; a null handle is a no-op, so calling it twice is safe. You do not need to stop anything on the way out - Play stopping and a scene load stop everything.
3D sound
A 3D sound comes from a place in the world. You hear it from its direction, quieter and duller the further away it is, and its pitch shifts as it passes you (the doppler effect). On an AudioSource, tick spatial: the sound comes from that object and follows it as it moves.

From a script, give playSound an at: an entity, which the sound follows, or a fixed point.
ts
// A looping engine on this car, following it.
this.engine = api.playSound("engine-loop", { at: api.entity, loop: true, minDistance: 4, maxDistance: 150 });
// A one-shot explosion where the rocket hit.
api.playSound("boom", { at: { x: 12, y: 0, z: -40 }, maxDistance: 300 });| Option | Default | Meaning |
|---|---|---|
at | none (not 3D) | An entity to follow, or a point { x, y, z }. |
minDistance | 2 | Full volume within this many metres. |
maxDistance | 80 | Faded out completely at this many metres. |
rolloff | 1 | How fast it falls away beyond minDistance. 2 falls twice as fast. |
doppler | 1 | How much pitch shift a moving sound gets. 0 turns it off. |
What you hear is worked out each frame from the listener, which is the camera you are looking through:
- Direction. Each 3D sound is panned with a head-related transfer function (HRTF), which models how a head and ears change a sound by direction. It is clearest on headphones.
- Distance. The level falls as
minDistance / distance, so it halves at twiceminDistanceand quarters at four times it, then fades to silence over the last fifth ofmaxDistance, so a sound never cuts off with a click. - Air. Beyond 4 ×
minDistancea low-pass filter takes off the top, as air does over distance: with the defaultminDistanceof 2, full range at 8 m, about 7 kHz at 40 m and 4.4 kHz at 80 m. Real air takes off less; the stronger cue reads as "far away". - Doppler. The pitch changes with how fast the sound and the camera close on or pull away from each other, against a speed of sound of 343 m/s: a car closing at 40 m/s plays about 13% high, and about 10% low as it pulls away. The shift is held between half and double, and an object that jumps more than 200 m/s between two frames counts as a teleport and shifts nothing.
Positions and settings glide over about 30 ms rather than stepping, so a sound moved every frame does not buzz.
Changing a sound while it plays
These take the handle playSound returned, and glide to the new value over about 30 ms, so a script can set them every frame:
| Call | What it changes |
|---|---|
api.setSoundVolume(h, v) | Its own volume, 0 to 1. The master volume still applies on top. |
api.setSoundPitch(h, p) | Pitch, as the pitch option. A recording plays faster with it. A 3D sound's doppler shift is applied on top. |
api.setSoundLowpass(h, hz) | A low-pass filter at hz: lower is more muffled (behind a door, under water). 0 takes it away. |
ts
// An engine loop recorded at 3000 rpm, played at the revs the car is at.
api.setSoundPitch(this.engine, rpm / 3000);
// Muffled while the player is under water.
api.setSoundLowpass(this.music, underwater ? 600 : 0);Synths
A synth is a sound made by code instead of read from a file. It suits sounds that have to follow the game continuously, where a recording can only be stretched: an engine at any revs, wind at any strength, a laser charging for as long as a button is held. You write it as a class in a script, play it with api.playSynth, and steer it while it plays with api.setSoundParam.
ts
// Wind: noise through a band-pass filter that opens up as the wind gets stronger.
export class Wind implements SoundSynth {
noise: InstanceType<Dsp["Noise"]>;
band: DspBiquad;
level: InstanceType<Dsp["Smooth"]>;
constructor(sampleRate: number, options: unknown, dsp: Dsp) {
this.noise = new dsp.Noise();
this.band = new dsp.Biquad(sampleRate);
this.level = new dsp.Smooth(sampleRate, 200);
}
process(out: Float32Array, params: SynthParams) {
for (let i = 0; i < out.length; i++) {
const s = this.level.next(params.strength ?? 0.3);
this.band.bandpass(200 + 1200 * s, 0.7);
out[i] = this.band.process(this.noise.pink()) * s;
}
}
}ts
import { Wind } from "Weather Synths";
export default class Weather implements ScriptBehavior {
wind: number | null = null;
onStart(api: ScriptApi) {
this.wind = api.playSynth(Wind, { volume: 0.6, params: { strength: 0.2 } });
}
onUpdate(api: ScriptApi) {
api.setSoundParam(this.wind, "strength", 0.2 + 0.6 * Math.abs(Math.sin(api.time * 0.1)));
}
}How it runs:
- On the audio thread. The engine builds the class in an AudioWorklet and asks
processfor the next 128 samples, mono, every 2.7 ms at 48 kHz, apart from the game's own frame. A slow frame does not stutter it, butprocesshas to finish inside that 2.7 ms. - From its own source. The audio thread has none of the game's modules, so the engine sends it the class's own text. Everything the class uses must be inside it (its fields, methods and static members) or come from the
dspkit its constructor is given. A class that reaches outside, for a constant at the top of its file say, stops on the audio thread and the Console names it. - Params.
paramsholds whatsetSoundParamlast set, pluspitch, which the engine sets:setSoundPitchtimes a 3D sound's doppler shift. A synth with a pitch should multiply its frequencies by it. Params are set once a frame, so smooth any that it would hear step (dsp.Smooth). - Options.
playSynth'soptionsare handed to the constructor, copied to the audio thread, so they must be plain data: numbers, strings, arrays and objects of those. - Ending. It plays until
stopSound, a scene load or Stop. Returnfalsefromprocessto end it from inside, as a one-shot does once it has died away.stopSoundfades a synth out over about 10 ms. - 3D.
playSynthtakes the same options asplaySound,atincluded. A 3D synth beyond itsmaxDistanceis paused: it does no work and makes no sound until it comes back into range, so a field of distant engines costs nothing. - Without a worklet, as on a
game.htmlopened from disk (file://), the class runs on the main thread instead, where a slow frame can stutter it. The Console says so for each synth class.
The DSP kit
The dsp argument holds the building blocks. Make them in the constructor, keep them in fields, and call them once per sample in process.
| Part | Use |
|---|---|
new dsp.Osc(sampleRate) | An oscillator: .sine(hz), .saw(hz), .square(hz, width?), .triangle(hz) return the next sample. Saw and square are band-limited (polyBLEP), so high notes do not alias. |
new dsp.Noise(seed?) | .white(), .pink() (falls 3 dB an octave: rain, wind), .brown() (6 dB: rumble). Seeded, so a synth sounds the same every run. |
new dsp.Biquad(sampleRate) | A two-pole filter. Set its shape with .lowpass(hz, q?), .highpass, .bandpass, .notch, .peak(hz, q, db), .lowshelf(hz, db) or .highshelf, as often as every sample, then .process(x). |
new dsp.OnePole(sampleRate, hz) | The cheapest gentle filter: .lowpass(x), .highpass(x). |
new dsp.Delay(maxSamples) | .write(x), then .read(n) for the sample from n ago (fractions interpolate). Echoes, combs, chorus, resonant pipes. |
new dsp.Smooth(sampleRate, ms) | .next(target) glides toward a target over about ms. |
new dsp.Adsr(sampleRate, a, d, s, r) | An envelope in seconds: .gate(true) starts a note, .gate(false) releases it, .next() per sample, .done once released. |
new dsp.DcBlock() | .process(x) removes a constant offset. |
dsp.saturate(x, drive?) | Soft clipping. A drive above 1 is warm distortion. |
dsp.mtof(note), dsp.db(db) | A MIDI note's frequency; decibels to a gain. |
How it works
One BrowserAudioEngine serves both the editor's Game view and the exported player, so what you hear while authoring is what ships. Headless contexts and tests get a NoopAudioEngine, which is why gameplay logic stays testable without a browser.
Every frame, Play tells the engine where the camera is and where each object a 3D sound follows has moved, which is where distance, air and doppler are worked out.
Details worth knowing:
- The context starts suspended. Browsers block audio until the player interacts with the page, so the engine resumes on the first
pointerdownorkeydown. A sound that fires before any input is silent until then - including music started at Play. - Each sound decodes once, however many times it plays, and the decoded buffer is cached by id.
- A sound can be stopped before it starts.
playhands back its handle immediately, while the decode is still running; stopping in that gap means it never starts. Stop pressed during the first frame of Play is exactly this case. - A file that will not decode is silent, not fatal. One bad import does not affect the rest, and the Console says which sound it was, its format and the browser's reason, once.
- A limiter guards the output. Above about -6 dB on the master bus it turns the mix down rather than letting it clip, so twenty sounds at once get quieter instead of crackling. Below that it does nothing.
Shipping
Sounds are saved in the .awaken project as raw bytes in its binary blob, and exported into game.html as base64 - the same carriage fonts and textures use.
Export ships every sound in the project, not just the ones it can see referenced. A sound is usually named by a bare string - an AudioSource clip, or api.playSound("hit") inside a script - and no component walk can find the second kind. Trimming to what the walk finds is how a game ships silent. Audio is large, so this costs real bytes: delete sounds you are not using rather than leaving them in the project.
Limitations
- No occlusion or reverb. A 3D sound behind a wall is as loud as one in the open, and there is no room echo.
setSoundLowpasscan fake the first. - No mixing groups or buses. Each sound has its own chain into the master, so there is no master music/SFX slider short of tracking your own handles.
- No fades on recordings.
stopSoundstops a recording at once (a synth fades over about 10 ms). Fade one out withsetSoundVolumefirst. - No streaming. A recording is decoded whole into memory before it plays, so a long track costs its full decoded size in RAM. MIDI has no such cost - it is sequenced as it plays.
- One MIDI at a time, as above.
See also
- Scripting API - the full
ScriptApi, includingplaySound,playSynth,setSoundParamandsetSoundTempo. - Script parameters - a
: Soundfield gives a script a sound picker in the Inspector. - Import Overview - the other file types the editor accepts.
- Export as one file - what ends up inside
game.html. - In-Game UI - the other overlay system that ships with your game.