Web Audio with Odin

For game jams, uploading web games to itch is quite common, so for future jams I wanted to get an overview of the options and searched the discord server for wasm and other options to deploy to desktop + web.
I know that raylib’s web build now works via emscripten - but I also sensed that emscripten does not seem to be ideal with odin.

A promising option for a way without emscripten seemed to be to use opengl for desktop platforms and wasm webgl for web.

After tinkering with this techstack (looking at the glfw wgpu example and gingerbill’s crow2d repo), I think basic graphics should be achievable, but soon realized that audio would eventually become a problem.

Also, the vendor miniaudio lib and sdl do not support wasm (only via emscripten).

So I wonder if anyone has found a way to achieve audio playback using Odin’s js_wasm32 target?

TL;DR
You need to write JavaScript functions like init_audio(), play_sound() to import as foreign procedures in Odin. You can use Web Audio API’s AudioContext to send raw audio buffer into AudioBuffer, then play it with AudioBufferSourceNode and write the resource management code in JavaScript. To send raw audio buffer from Odin, you can do so by passing byte slices into foreign procedures and load them in Javascript using WasmMemoryInterface Odin provided in odin.js.

I managed to achieve a working audio playback in my custom engine (uses wgpu as its graphics API) after some web searching and LLM conversations. Here’s my approach (or the whole code actually):

Disclaimer: I don’t have experience in audio programming at all, some terms I use might be incorrect here. This only covers playing of the whole buffer (load the whole file before playing), not audio streaming (stream the data in while playing). If you know how to play sound via audio streaming, feel free to provide the solution or high-level steps here. :smiley:

index.html

Odin’s example of working with js_wasm target is that you have to copy odin.js and use it in index.html in order to use its language features like memory allocation, foreign procedures, exports, etc. You can provide your JavaScript file (my_engine.js is this case) to interface with odin.js.

<!DOCTYPE html>
<html lang="en" style="height: 100%;">
	<!-- omitted -->
	<body id="body" style="height: 100%; padding: 0; margin: 0; overflow: hidden;">
		<canvas id="wgpu-canvas" style="height: 100%; width: 100%;"></canvas>
		<!-- odin.js and wgpu.js is provided with your installation of Odin -->
		<script type="text/javascript" src="odin.js"></script>
		<script type="text/javascript" src="wgpu.js"></script>
		<!-- this file you have to provide yourself -->
		<script type="text/javascript" src="my_engine.js"></script>
		<script type="text/javascript">
			(async () => {
				const mem = new WebAssembly.Memory({ initial: 2000, maximum: 65536, shared: false });
				const memInterface = new odin.WasmMemoryInterface();
				memInterface.setMemory(mem);

				const wgpuInterface = new odin.WebGPUInterface(memInterface);
				const myInterface = new odin.MyInterface(memInterface);

				await myInterface.initialize();

				odin.runWasm("game.wasm", null, {
					wgpu: wgpuInterface.getInterface(),
					my_web_interface: myInterface.getInterface(),
				}, memInterface, /*intSize=8*/);
			})();
		</script>
	</body>
</html>

my_engine.js

You can hold a reference to Odin’s WasmMemoryInterface, then load data from your Odin program using .loadBytes(), .loadString(), etc. With this interface, we can now receive pointer and length as arguments from our Odin program and load them in JavaScript to initialize audio buffers.

I recommend reading the code inside odin.js to see what can you do with the interface.

(function() {

    class MyInterface {

        constructor(mem) {
            // Odin's memory interface
            this.mem = mem;

            // Audio
            this.audioCtx = new window.AudioContext();
            this.audioBuses = []; // Will get populated by js_init_buses() from wasm.
            this.soundInstances = []; // TODO: use generational array instead

            // omitted: other attributes for other features
        }

        async initialize() {
            // omitted: Initialization logic that requires async calls.
            // In my case, it was asset loading code that used fetch() and OPFS.
        }

        getInterface() {
            return {
                js_init_buses: (bufPtr, bufLen) => {
                    const parents = this.mem.loadI32Array(bufPtr, bufLen);
                    for (let i = 0; i < parents.length; ++i) {
                        this.audioBuses.push(this.audioCtx.createGain());
                    }
                    for (let i = 0; i < parents.length; ++i) {
                        const child = this.audioBuses[i];
                        const parentIdx = parents[i];
                        if (parentIdx < 0) {
                            // Connect bus to AudioContext's master if given parent idx is negative
                            child.connect(this.audioCtx.destination);
                        } else if (parentIdx < parents.length) {
                            child.connect(this.audioBuses[parentIdx]);
                        } else {
                            console.error(`Invalid bus #${i}'s parent index given (${parentIdx}), only has ${parents.length} bus(es).`);
                        }
                    }
                },

                js_set_bus_volume: (busIdx, volume) => {
                    this.audioBuses[busIdx].gain.setValueAtTime(volume, this.audioCtx.currentTime);
                },

                // WARN: You might not want to play long sounds (like music) this way.
                js_play_sound: (bufPtr, bufLen, sampleRate, channelCnt, busIdx, looping) => {
                    // bufLen is only per channel, so we need to multiply by channelCnt to read the whole buffer.
                    // Took me waaaaaayyy too long to figure this out.
                    const actualBufLen = bufLen * channelCnt;
                    const audioBytes = this.mem.loadI16Array(bufPtr, actualBufLen);
                    const audioBuffer = this.audioCtx.createBuffer(channelCnt, bufLen, sampleRate);
                    for (let channel = 0; channel < channelCnt; channel++) {
                        const channelData = audioBuffer.getChannelData(channel);
                        for (let i = 0; i < bufLen; ++i) {
                            // Convert i16 (-32768 to 32767) to Float32 (-1.0 to 1.0)
                            // I haven't tried converting from Odin side though, there might be a difference.
                            const sampleIndex = i * channelCnt + channel;
                            channelData[i] = audioBytes[sampleIndex] / 32768.0;
                        }
                    }
                    const bus = this.audioBuses[busIdx];
                    const inst = new MySoundInstance(this.audioCtx, audioBuffer, bus, looping);
                    inst.start();
                    this.soundInstances.push(inst);
                    // TODO: write to idx, gen pointers instead of returning raw index
                    return this.soundInstances.length - 1;
                },

                js_start_sound: (handle) => {
                    this.soundInstances[handle].start();
                },

                js_stop_sound: (handle) => {
                    this.soundInstances[handle].stop();
                },

                js_pause_sound: (handle) => {
                    this.soundInstances[handle].pause();
                },

                js_resume_sound: (handle) => {
                    this.soundInstances[handle].resume();
                },

                js_sound_is_playing: (handle) => {
                    return this.soundInstances[handle].isPlaying;
                }
            }
        }
    }

    class MySoundInstance {
        constructor(audioCtx, buffer, bus, looping) {
            this.audioCtx = audioCtx;
            this.buffer = buffer;
            this.bus = bus;
            this.looping = looping;
            this.source = null;
            this.pauseOffset = 0;
            this.resumeOffset = 0;
            this.isPlaying = false;
        }

        #createAndConnectSource() {
            this.source = this.audioCtx.createBufferSource();
            this.source.buffer = this.buffer;
            this.source.loop = this.looping;
            this.source.connect(this.bus);
        }

        start() {
            if (this.isPlaying) return;
            this.#createAndConnectSource();
            this.resumeOffset = this.audioCtx.currentTime;
            this.source.start(0, 0);
            this.isPlaying = true;
        }

        resume() {
            if (this.isPlaying) return;
            this.#createAndConnectSource();
            this.resumeOffset = this.audioCtx.currentTime - this.pauseOffset;
            this.source.start(0, this.pauseOffset);
            this.isPlaying = true;
        }

        stop() {
            if (!this.isPlaying) return;
            this.pauseOffset = 0;
            this.source.stop();
            this.isPlaying = false;
        }

        pause() {
            if (!this.isPlaying) return;
            let elapsed = this.audioCtx.currentTime - this.resumeOffset;
            // Wrap elapsed time to make resume() work correctly on looping sounds.
            this.pauseOffset = elapsed % this.buffer.duration;
            this.source.stop();
            this.isPlaying = false;
        }
    }

    window.odin = window.odin || {};
    window.odin.MyInterface = MyInterface;

})();

Your odin code

I stripped things down here. Basically, you define a foreign block containing functions in my_engine.js and call those foreign functions in your odin procedure. Note that slice and string type as parameter gets converted to 2 parameters on JavaScript side (pointer and length).

// omitted

import "core:c"
import "vendor:stb/vorbis" // Odin's stb_vorbis supports js_wasm32 target!

foreign import web "my_interface_web" // Must match with runWasm() key in index.html

@(default_calling_convention = "contextless")
foreign web {
	// Each element is a bus, containing the index to its parent.
	// Negative parent index signify that the bus doesn't have a parent,
	// and will get connected directly to AudioContext's destination.
	js_init_buses :: proc(parents: []i32) ---
	js_set_bus_volume :: proc(bus_idx: Audio_Bus_Name, volume: f32) ---
	js_play_sound :: proc(audio_buf: []i16, sample_rate: int, channel_cnt: int, bus_idx: Audio_Bus_Name, looping: bool) -> int ---
	js_start_sound :: proc(handle: int) ---
	js_stop_sound :: proc(handle: int) ---
	js_resume_sound :: proc(handle: int) ---
	js_pause_sound :: proc(handle: int) ---
	js_sound_is_playing :: proc(handle: int) -> bool ---
}

Audio_Bus_Name :: enum {
	Master,
	Sfx,
	Music,
}

init_audio :: proc() {
	// Parent of Master, Sfx, Music in order
	js_init_buses(parents = {-1, 0, 0})
}

Sound_Handle :: distinct int

// WARN: This procedure is only a naive implementation and leaks resources!
// You have to implement a way to free those resources via handle.
play_sound :: proc(sound_file_path: string, bus: Audio_Bus_Name, looping := false) -> Sound_Handle {
	// NOTE: You can implement caching here if you want.
	data := os_load_file(sound_file_path) // Out of scope for this topic
	defer delete(data)
	channels, sample_rate: c.int
	decoded: [^]c.short
	// We use stb_vorbis to decode .ogg files into raw audio buffer
	// that our JavaScript class can use.
	// This allocates memory to `decoded`.
	n := vorbis.decode_memory(
		raw_data(data),
		i32(len(data)),
		&channels,
		&sample_rate,
		&decoded,
	)
	buf := decoded[:n]
	handle := js_play_sound(buf, int(sample_rate), int(channels), bus, looping)
	return Sound_Handle(handle)
}

// omitted

That’s all! I won’t get into replicating file system in js_wasm32 target here, as that’s a whole another topic.

Some notes:

  • Audio_Bus_Name enum is only an integer on JavaScript side.
  • Sound_Handle here shouldn’t be a single int, but an actual handle that holds data JavaScript can use to manage the sound resources. My idea is to use a generational array (handle_map in Odin) instead and hold gen + idx. But I haven’t implemented that yet. :man_shrugging:
  • The odin code only covers playing of OGG format. I’m pretty sure you can support WAV easily as the format is uncompressed.
  • This is just *my way* to achieve audio playback on the web. You can apply my writeup here however you like that match your codebase/workflow.

Please correct me if I’m wrong in some places. Or improve on my method if it’s inefficient (I’m sure it is).