// src/sound.zig - runtime audio API (raylib-shaped surface).
// Wraps `web.audio` (Web Audio JS bridge) and `codecs.audio` (WAV
// codec + format dispatch) into the Wave/Sound/Music/AudioStream
// types raylib programs expect.  Layout matches the raylib 6.0
// audio surface as closely as the web platform allows:
//   audio_device - per-process singleton context lifecycle
//   waves        - Wave-typed loading + manipulation + filter chain
//   composer     - zimr-original synthesis primitives
//   sounds       - Sound-typed playback (one-shot SFX)
//   music        - Music-typed streaming (Phase 6+)
// Web platform constraints worth noting:
//   - Multiple AudioContexts work in modern browsers but burn CPU;
//     `audio_device` owns ONE shared context.
//   - `decodeAudioData` is async; OGG sounds enter a "pending" state
//     and finalize a few frames later (`isSoundReady` polls).
//   - Source nodes are one-shot.  Every `playSound` call creates a
//     fresh node - `loadSoundAlias` is degenerate (returns the same
//     buffer-id; aliasing is implicit at play time).

const std = @import("std");
const ArrayList = std.ArrayList;
const expect = std.testing.expect;
const expectApproxEqAbs = std.testing.expectApproxEqAbs;
const expectEqual = std.testing.expectEqual;
const expectEqualSlices = std.testing.expectEqualSlices;
const expectError = std.testing.expectError;
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const zm = @import("zm");
const float64 = zm.float64;
const clamp = zm.clamp;
const float = zm.float;
const tau = zm.tau;

const web = @import("web.zig");
const codecs = @import("codecs.zig");
const types = @import("types.zig");

const Wave = types.Wave;
const Sound = types.Sound;
const Music = types.Music;
const AudioStream = types.AudioStream;

// ============================================================================
// SECTION - audio_device (raylib's InitAudioDevice / CloseAudioDevice family)
// ============================================================================
// Process-singleton AudioContext.  raylib's audio API operates on
// an implicit global device; we mirror that here so users can
// write `audio_device.init(audio_device.&ws)` once at startup and forget about it.
// On host (non-wasm), `init()` returns successfully (`isReady`
// reports true) but no real context exists - the JS bridge is
// stubbed.  This lets host tests exercise the wrapper logic
// without a browser.

/// Spectrum analysis — a WebAudio `AnalyserNode` tapped off the master bus.
///
/// It is a TAP, not an insert: the master output already reaches the speakers, and
/// the analyser just receives a copy, so attaching one cannot change what you
/// hear. `fft_size` must be a power of two in [32, 32768] and you get `fft_size/2`
/// usable bins.
pub const analyser = struct {
    pub const Analyser = struct {
        id: web.audio.AnalyserId = 0,
        bins: u32 = 0,
    };

    /// Attach an analyser to the device's master bus.
    pub fn attach(device: *const audio_device.AudioDeviceState, fft_size: u32) Analyser {
        const id: web.audio.AnalyserId = web.audio.createAnalyser(device.ctx_id, fft_size);
        if (id == 0) {
            return .{};
        }
        return .{ .id = id, .bins = fft_size / 2 };
    }

    /// Fill `out` with the live magnitude spectrum (0..255 per bin). Returns how
    /// many bins were written — 0 if there is no analyser, which is also what the
    /// native (non-wasm) host does, so callers must handle a silent result.
    pub fn read(
        device: *const audio_device.AudioDeviceState,
        a: Analyser,
        out: []u8,
    ) u32 {
        if (a.id == 0) {
            return 0;
        }
        return web.audio.getFrequencyData(device.ctx_id, a.id, out);
    }

    pub fn detach(device: *const audio_device.AudioDeviceState, a: Analyser) void {
        if (a.id == 0) {
            return;
        }
        web.audio.destroyAnalyser(device.ctx_id, a.id);
    }
};

pub const waves = struct {
    /// Allocator used to free wave data.  Stashed at load time;
    /// `unload` consults it.  We keep this in a side-table because
    /// raylib's `Wave` extern struct has no allocator field - we
    /// can't smuggle a per-Wave allocator pointer in the FFI shape.
    /// Storage relocated to user `State` in Phase C4
    /// apps using audio reserve `audio: z.AudioState` in their
    /// State and pass `&state.audio.waves` through the explicit
    /// fn signatures.  Tests construct a stack-local
    /// `var ws: AllocTable = .{};` and pass `&ws`.
    /// Side-table mapping `data` pointers to the allocator used to
    /// allocate them.  Sized to match typical SFX-pool capacity (a
    /// few dozen waves resident at peak).  Falls back to scanning
    /// linearly - fine for this scale; can swap to AutoHashMap if
    /// it becomes a hot path.
    pub const AllocTable = struct {
        const Capacity = 64;
        const Entry = struct {
            ptr: ?*anyopaque = null,
            gpa: ?Allocator = null,
        };
        entries: [Capacity]Entry = @splat(.{}),

        fn put(
            self: *AllocTable,
            ptr: *anyopaque,
            gpa: Allocator,
        ) void {
            for (&self.entries) |*e| {
                if (e.ptr == null) {
                    e.* = .{ .ptr = ptr, .gpa = gpa };
                    return;
                }
            }
            // Out of slots.  Drop the registration silently - the
            // wave data leaks on unload.  Bumping `Capacity` is the
            // fix; for now we log at the highest-volume call site
            // so it surfaces if it ever happens.
        }

        fn take(self: *AllocTable, ptr: *anyopaque) ?Allocator {
            for (&self.entries) |*e| {
                if (e.ptr == ptr) {
                    const gpa: ?Allocator = e.gpa;
                    e.* = .{};
                    return gpa;
                }
            }
            return null;
        }
    };

    /// Decode `bytes` into a `Wave`.  `file_type` is a hint string
    /// like ".wav" or ".ogg" - used to pick a codec when magic-byte
    /// sniffing is ambiguous.  Currently the magic-byte sniff
    /// (`Format.detect`) wins; `file_type` is informational.
    /// For sync-decodable formats (WAV) the decoded Wave is
    /// returned immediately.  For OGG (browser-only path) this
    /// errors with `error.OggRequiresAsyncDecode` - callers should
    /// use `sounds.loadFromMemory` instead, which has the
    /// async-friendly Sound type.
    /// Returns an empty Wave on decode failure (frameCount = 0,
    /// data = null) - matches raylib's contract that
    /// `IsWaveValid` distinguishes good from bad waves.
    pub fn loadFromMemory(
        state: *AllocTable,
        gpa: Allocator,
        file_type: []const u8,
        bytes: []const u8,
    ) !Wave {
        _ = file_type;
        const fmt: codecs.audio.Format = codecs.audio.Format.detect(bytes) orelse return error.UnknownAudioFormat;
        if (!fmt.syncDecodable()) {
            return error.OggRequiresAsyncDecode;
        }
        var reader: std.Io.Reader = std.Io.Reader.fixed(bytes);
        const cw: codecs.audio.CanonicalWave = try fmt.decode(gpa, &reader);
        defer cw.deinit();
        const w: Wave = try codecs.audio.waveFromCanonical(gpa, cw);
        if (w.data) |p| {
            state.put(p, gpa);
        }
        return w;
    }

    /// Free a Wave's sample data.  Idempotent on null/zero waves.
    pub fn unload(
        state: *AllocTable,
        wave: Wave,
    ) void {
        const data: *anyopaque = wave.data orelse return;
        const gpa: Allocator = state.take(data) orelse return;
        const byte_len: usize = @as(usize, wave.frameCount) *
            @as(usize, wave.channels) *
            (@as(usize, wave.sampleSize) / 8);
        if (byte_len == 0) {
            return;
        }
        const data_ptr: [*]u8 = @ptrCast(data);
        gpa.free(data_ptr[0..byte_len]);
    }

    /// Whether the Wave's parameters describe loaded, well-formed
    /// audio.
    pub fn isValid(wave: Wave) bool {
        if (wave.data == null) {
            return false;
        }
        if (wave.frameCount == 0) {
            return false;
        }
        if (wave.sampleRate == 0) {
            return false;
        }
        if (wave.channels == 0) {
            return false;
        }
        // sampleSize: only the bit depths we support.
        return wave.sampleSize == 8 or wave.sampleSize == 16 or wave.sampleSize == 32;
    }

    /// Deep-copy a Wave (allocates fresh sample data).  The new
    /// wave is owned by `gpa`; caller `unload`s it.
    pub fn copy(
        state: *AllocTable,
        gpa: Allocator,
        wave: Wave,
    ) !Wave {
        const byte_len: usize = @as(usize, wave.frameCount) *
            @as(usize, wave.channels) *
            (@as(usize, wave.sampleSize) / 8);
        const new_bytes = try gpa.alloc(u8, byte_len);
        errdefer gpa.free(new_bytes);
        if (wave.data) |src| {
            const src_ptr: [*]const u8 = @ptrCast(src);
            @memcpy(new_bytes, src_ptr[0..byte_len]);
        }
        const new_wave: Wave = .{
            .frameCount = wave.frameCount,
            .sampleRate = wave.sampleRate,
            .sampleSize = wave.sampleSize,
            .channels = wave.channels,
            .data = @as(?*anyopaque, @ptrCast(new_bytes.ptr)),
        };
        state.put(@ptrCast(new_bytes.ptr), gpa);
        return new_wave;
    }

    /// Crop the wave in-place to the frame range `[init_frame, final_frame)`.
    /// Frees the original sample data and replaces it with a
    /// freshly-allocated subrange.  Out-of-range frames are clamped
    /// to the wave's bounds; if the resulting range is empty the
    /// wave becomes empty (frameCount = 0, data = null).
    pub fn crop(
        state: *AllocTable,
        gpa: Allocator,
        wave: *Wave,
        init_frame: u32,
        final_frame: u32,
    ) !void {
        if (!isValid(wave.*)) {
            return;
        }
        const start: u32 = @min(init_frame, wave.frameCount);
        const end: u32 = @min(final_frame, wave.frameCount);
        if (start >= end) {
            unload(state, wave.*);
            wave.* = .{
                .frameCount = 0,
                .sampleRate = wave.sampleRate,
                .sampleSize = wave.sampleSize,
                .channels = wave.channels,
                .data = null,
            };
            return;
        }
        const bytes_per_frame: u32 = wave.channels * (wave.sampleSize / 8);
        const new_byte_len: usize = (end - start) * bytes_per_frame;
        const new_bytes = try gpa.alloc(u8, new_byte_len);
        errdefer gpa.free(new_bytes);
        if (wave.data) |src| {
            const src_ptr: [*]const u8 = @ptrCast(src);
            const start_byte: usize = start * bytes_per_frame;
            @memcpy(new_bytes, src_ptr[start_byte .. start_byte + new_byte_len]);
        }
        unload(state, wave.*);
        wave.* = .{
            .frameCount = end - start,
            .sampleRate = wave.sampleRate,
            .sampleSize = wave.sampleSize,
            .channels = wave.channels,
            .data = @as(?*anyopaque, @ptrCast(new_bytes.ptr)),
        };
        state.put(@ptrCast(new_bytes.ptr), gpa);
    }

    /// Convert the wave to a desired sample rate / bit depth /
    /// channel count, in place.  Goes through the canonical f32
    /// stereo intermediate via `toFloat32Stereo` + `resampleLinear`,
    /// then quantizes back to the requested bit depth.
    /// Supported `sample_size` values: 8, 16, 32.  Channel counts:
    /// 1 (mono), 2 (stereo).  Other values are unchanged.
    /// Note: 8-bit conversion is lossy; use 16-bit or 32-bit for
    /// quality.
    pub fn format(
        state: *AllocTable,
        gpa: Allocator,
        wave: *Wave,
        sample_rate: u32,
        sample_size: u32,
        channels: u32,
    ) !void {
        if (!isValid(wave.*)) {
            return;
        }
        if (sample_size != 8 and sample_size != 16 and sample_size != 32) {
            return error.UnsupportedSampleSize;
        }
        if (channels != 1 and channels != 2) {
            return error.UnsupportedChannelCount;
        }

        const cw: codecs.audio.CanonicalWave = codecs.audio.canonicalFromWave(wave.*);
        // Step 1: canonical f32 stereo at source rate.
        const stereo_f32: []f32 = try codecs.audio.wav.toFloat32Stereo(gpa, cw);
        defer gpa.free(stereo_f32);

        // Step 2: resample to target rate if needed.
        const resampled: []f32 = if (sample_rate == wave.sampleRate)
            try gpa.dupe(f32, stereo_f32)
        else
            try codecs.audio.wav.resampleLinear(gpa, stereo_f32, 2, wave.sampleRate, sample_rate);
        defer gpa.free(resampled);

        // Step 3: quantize back to requested bit depth, downmixing
        // to mono if requested.
        const out_frames: usize = resampled.len / 2;
        const bytes_per_sample: usize = sample_size / 8;
        const out_byte_len: usize = out_frames * channels * bytes_per_sample;
        const out_bytes = try gpa.alloc(u8, out_byte_len);
        errdefer gpa.free(out_bytes);

        for (0..out_frames) |f| {
            const l: f32 = resampled[f * 2 + 0];
            const r: f32 = resampled[f * 2 + 1];
            const mono: f32 = (l + r) * 0.5;
            for (0..channels) |c| {
                const sample: f32 = switch (channels) {
                    1 => mono,
                    2 => if (c == 0) l else r,
                    else => 0.0,
                };
                writeSampleAt(out_bytes, f, c, channels, sample_size, sample);
            }
        }

        unload(state, wave.*);
        wave.* = .{
            .frameCount = @intCast(out_frames),
            .sampleRate = sample_rate,
            .sampleSize = sample_size,
            .channels = channels,
            .data = @as(?*anyopaque, @ptrCast(out_bytes.ptr)),
        };
        state.put(@ptrCast(out_bytes.ptr), gpa);
    }

    /// Write one f32 sample (in `[-1, 1]` range) into `out` at the
    /// given frame/channel slot, quantized to `sample_size` bits.
    fn writeSampleAt(
        out: []u8,
        frame: usize,
        channel: usize,
        channels: u32,
        sample_size: u32,
        value: f32,
    ) void {
        const bytes_per_sample: usize = sample_size / 8;
        const offset: usize = (frame * channels + channel) * bytes_per_sample;
        const clamped: f32 = clamp(value, -1.0, 1.0);
        switch (sample_size) {
            8 => {
                // Unsigned with bias 128.
                const v_i: i32 = @round(clamped * 127.0 + 128.0);
                out[offset] = @intCast(clamp(v_i, 0, 255));
            },
            16 => {
                const v_i: i32 = @round(clamped * 32767.0);
                const clipped: i16 = @intCast(clamp(v_i, -32768, 32767));
                std.mem.writeInt(i16, out[offset..][0..2], clipped, .little);
            },
            32 => {
                // 32-bit IEEE float passthrough (no clipping needed).
                std.mem.writeInt(u32, out[offset..][0..4], @bitCast(clamped), .little);
            },
            else => {},
        }
    }

    /// Decode a Wave to a fresh `[]f32` interleaved (per-channel
    /// layout matches the wave's channel count).  Mirror of
    /// raylib's `LoadWaveSamples`; caller frees via
    /// `unloadSamples`.
    /// For raylib parity the output channel count matches the
    /// input wave's.  For canonical-stereo output, callers should
    /// use `codecs.audio.wav.toFloat32Stereo` directly.
    pub fn loadSamples(
        gpa: Allocator,
        wave: Wave,
    ) ![]f32 {
        if (!isValid(wave)) {
            return try gpa.alloc(f32, 0);
        }
        // toFloat32Stereo always emits 2 channels - for raylib
        // parity we need to either passthrough (already stereo) or
        // duplicate (mono → stereo would change the channel count
        // semantically).  Easier path: walk the bytes ourselves
        // using the same per-bit-depth normalization.
        const cw: codecs.audio.CanonicalWave = codecs.audio.canonicalFromWave(wave);
        const ch: usize = wave.channels;
        const fc: usize = wave.frameCount;
        const out = try gpa.alloc(f32, fc * ch);
        errdefer gpa.free(out);
        const bytes_per_sample: usize = wave.sampleSize / 8;
        for (0..fc) |f| {
            for (0..ch) |c| {
                const byte_off: usize = (f * ch + c) * bytes_per_sample;
                out[f * ch + c] = readSampleAt(cw, byte_off);
            }
        }
        return out;
    }

    /// Free a buffer returned by `loadSamples`.
    pub fn unloadSamples(
        gpa: Allocator,
        samples: []f32,
    ) void {
        gpa.free(samples);
    }

    /// Encode a Wave to RIFF/WAVE bytes.  Returns owned bytes; caller
    /// frees via `gpa.free`.  Output format mirrors the wave's input
    /// format - `wave.sampleSize` and the implicit format tag (PCM
    /// int for 8/16-bit, IEEE float for 32-bit) are written as-is.
    /// raylib's `ExportWave(wave, fileName)` writes to disk; on the
    /// web we have no filesystem, so this returns bytes the caller
    /// can hand to `dom.downloadBlob` or fetch upload.  Same name
    /// space, different I/O.
    pub fn exportToMemory(
        gpa: Allocator,
        wave: Wave,
    ) ![]u8 {
        if (!isValid(wave)) {
            return error.InvalidWave;
        }
        const cw: codecs.audio.CanonicalWave = codecs.audio.canonicalFromWave(wave);
        const fmt_code: codecs.audio.wav.ExportOptions = .{
            .bits = @intCast(wave.sampleSize),
            .format_code = if (wave.sampleSize == 32) .ieee_float else .pcm,
        };
        var aw: std.Io.Writer.Allocating = .init(gpa);
        errdefer aw.deinit();
        try codecs.audio.wav.encode(cw, &aw.writer, fmt_code);
        return aw.toOwnedSlice();
    }

    /// Read one sample from a CanonicalWave, normalized to f32.
    /// Same conversion as `wav.readSample` but exposed at this
    /// layer so we don't reach into a private fn.
    fn readSampleAt(cw: codecs.audio.CanonicalWave, byte_offset: usize) f32 {
        const bytes_per_sample: usize = cw.sample_size / 8;
        const slice: []const u8 = cw.samples[byte_offset .. byte_offset + bytes_per_sample];
        return switch (cw.sample_size) {
            8 => blk: {
                const raw: u8 = slice[0];
                const centered: f32 = float(@as(i16, raw) - 128);
                break :blk centered / 128.0;
            },
            16 => blk: {
                const raw: i16 = std.mem.readInt(i16, slice[0..2], .little);
                break :blk float(raw) / 32768.0;
            },
            32 => blk: {
                if (cw.format_tag == .ieee_float) {
                    const raw_u: u32 = std.mem.readInt(u32, slice[0..4], .little);
                    break :blk @as(f32, @bitCast(raw_u));
                }
                const raw: i32 = std.mem.readInt(i32, slice[0..4], .little);
                break :blk float(raw) / 2147483648.0;
            },
            else => 0.0,
        };
    }

    /// Apply a filter function in-place, freeing the original
    /// sample data once the filter has produced its replacement.
    /// Inspired by lightmix's `filter_with` pattern: filters
    /// take a Wave value, return a fresh Wave; the chain mutator
    /// handles ownership transfer + cleanup.
    /// The filter receives the current Wave and the args tuple;
    /// returns a fresh Wave (must be heap-allocated by the
    /// filter via `gpa`).  After this call, `wave.*` holds the
    /// new wave; the old one is freed.
    /// Composition example:
    ///     try waves.filter(gpa, &w, fadeIn, .{ .ms = 50 });
    ///     try waves.filter(gpa, &w, normalize, .{});
    ///     try waves.filter(gpa, &w, lowpass, .{ .cutoff = 1500.0 });
    pub fn filter(
        state: *AllocTable,
        gpa: Allocator,
        wave: *Wave,
        comptime filter_fn: anytype,
        args: anytype,
    ) !void {
        const new_wave: Wave = try filter_fn(gpa, wave.*, args);
        unload(state, wave.*);
        wave.* = new_wave;
        // Register the new wave's allocation so a later `unload`
        // can free it.  filter_fn allocates with `gpa` but
        // doesn't (and shouldn't) know about the AllocTable.
        if (new_wave.data) |p| {
            state.put(p, gpa);
        }
    }

    // ---- Tests
    test "loadFromMemory + isValid + unload: WAV round trip" {
        const ta: Allocator = std.testing.allocator;
        var ws: AllocTable = .{};
        const w: Wave = try loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&ws, w);
        try expect(isValid(w));
        try expectEqual(@as(u32, 11025), w.frameCount);
        try expectEqual(@as(u32, 22050), w.sampleRate);
        try expectEqual(@as(u32, 1), w.channels);
        try expectEqual(@as(u32, 16), w.sampleSize);
    }

    test "loadFromMemory rejects unknown format" {
        const ta: Allocator = std.testing.allocator;
        var ws: AllocTable = .{};
        try expectError(
            error.UnknownAudioFormat,
            loadFromMemory(&ws, ta, ".png", "\x89PNG\r\n\x1a\n"),
        );
    }

    test "loadFromMemory rejects OGG (must use sounds.loadFromMemory)" {
        const ta: Allocator = std.testing.allocator;
        var ws: AllocTable = .{};
        try expectError(
            error.OggRequiresAsyncDecode,
            loadFromMemory(&ws, ta, ".ogg", "OggS\x00\x02\x00\x00\x00\x00"),
        );
    }

    test "isValid: rejects empty / null / zero-rate" {
        try expect(!isValid(.{}));
        try expect(
            !isValid(.{ .frameCount = 100, .sampleRate = 44100, .sampleSize = 16, .channels = 1, .data = null }),
        );
        // Non-null data but invalid bit depth.
        var dummy: u8 = 0;
        const data: ?*anyopaque = @ptrCast(&dummy);
        try expect(
            !isValid(.{ .frameCount = 100, .sampleRate = 44100, .sampleSize = 24, .channels = 1, .data = data }),
        );
    }

    test "copy: deep copy with independent ownership" {
        const ta: Allocator = std.testing.allocator;
        var ws: AllocTable = .{};
        const orig: Wave = try loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&ws, orig);
        const dup: Wave = try copy(&ws, ta, orig);
        defer unload(&ws, dup);
        try expect(orig.data != dup.data);
        try expectEqual(orig.frameCount, dup.frameCount);
        // Data bytes equal.
        const orig_ptr: [*]const u8 = @ptrCast(orig.data.?);
        const dup_ptr: [*]const u8 = @ptrCast(dup.data.?);
        const byte_len: usize = orig.frameCount * orig.channels * (orig.sampleSize / 8);
        try expectEqualSlices(u8, orig_ptr[0..byte_len], dup_ptr[0..byte_len]);
    }

    test "crop: shrinks frame range" {
        const ta: Allocator = std.testing.allocator;
        var ws: AllocTable = .{};
        var w: Wave = try loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&ws, w);
        try crop(&ws, ta, &w, 1000, 5000);
        try expectEqual(@as(u32, 4000), w.frameCount);
        try expect(isValid(w));
    }

    test "crop: out-of-range bounds clamp to empty" {
        const ta: Allocator = std.testing.allocator;
        var ws: AllocTable = .{};
        var w: Wave = try loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&ws, w);
        try crop(&ws, ta, &w, 100000, 200000);
        try expectEqual(@as(u32, 0), w.frameCount);
        try expectEqual(@as(?*anyopaque, null), w.data);
        // Now invalid (no data) - unload is a no-op.
        try expect(!isValid(w));
    }

    test "format: 22050 mono 16-bit → 44100 stereo 16-bit" {
        const ta: Allocator = std.testing.allocator;
        var ws: AllocTable = .{};
        var w: Wave = try loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&ws, w);
        try format(&ws, ta, &w, 44100, 16, 2);
        try expectEqual(@as(u32, 44100), w.sampleRate);
        try expectEqual(@as(u32, 2), w.channels);
        try expectEqual(@as(u32, 16), w.sampleSize);
        // 11025 source frames at 22050 Hz, doubled → 22050 frames at 44100 Hz.
        try expectEqual(@as(u32, 22050), w.frameCount);
    }

    test "loadSamples / unloadSamples: f32 roundtrip" {
        const ta: Allocator = std.testing.allocator;
        var ws: AllocTable = .{};
        const w: Wave = try loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&ws, w);
        const samples: []f32 = try loadSamples(ta, w);
        defer unloadSamples(ta, samples);
        try expectEqual(@as(usize, 11025), samples.len);
        // Samples are normalized to [-1, 1].
        for (samples) |s| {
            try expect(s >= -1.0 and s <= 1.0);
        }
    }

    test "filter chain: scale + normalize" {
        const ta: Allocator = std.testing.allocator;
        var ws: waves.AllocTable = .{};
        var w: Wave = try loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&ws, w);

        // A simple in-place "halve" filter: build a new wave with
        // each 16-bit sample divided by 2.
        const halve = struct {
            fn run(gpa: Allocator, src: Wave, _: anytype) !Wave {
                const byte_len: usize = src.frameCount * src.channels * (src.sampleSize / 8);
                const new_bytes = try gpa.alloc(u8, byte_len);
                errdefer gpa.free(new_bytes);
                const src_ptr: [*]const u8 = @ptrCast(src.data.?);
                const sample_count: usize = byte_len / 2;
                for (0..sample_count) |i| {
                    const off = i * 2;
                    const v: i16 = std.mem.readInt(i16, src_ptr[off..][0..2], .little);
                    std.mem.writeInt(i16, new_bytes[off..][0..2], @divTrunc(v, 2), .little);
                }
                const new_wave: Wave = .{
                    .frameCount = src.frameCount,
                    .sampleRate = src.sampleRate,
                    .sampleSize = src.sampleSize,
                    .channels = src.channels,
                    .data = @as(?*anyopaque, @ptrCast(new_bytes.ptr)),
                };
                // filter() registers the alloc; we don't need to.
                return new_wave;
            }
        }.run;

        const orig_first: i16 = blk: {
            const ptr: [*]const u8 = @ptrCast(w.data.?);
            break :blk std.mem.readInt(i16, ptr[0..2], .little);
        };
        try filter(&ws, ta, &w, halve, .{});
        const new_first: i16 = blk: {
            const ptr: [*]const u8 = @ptrCast(w.data.?);
            break :blk std.mem.readInt(i16, ptr[0..2], .little);
        };
        try expectEqual(@divTrunc(orig_first, 2), new_first);
        // frameCount/etc preserved across filter.
        try expectEqual(@as(u32, 11025), w.frameCount);
    }

    test "exportToMemory: round-trip via decode" {
        const ta: Allocator = std.testing.allocator;
        var ws: AllocTable = .{};
        const orig: Wave = try loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&ws, orig);

        const wav_bytes: []u8 = try exportToMemory(ta, orig);
        defer ta.free(wav_bytes);
        // Encoded bytes start with RIFF/WAVE.
        try expect(wav_bytes.len >= 44);
        try expectEqualSlices(u8, "RIFF", wav_bytes[0..4]);
        try expectEqualSlices(u8, "WAVE", wav_bytes[8..12]);

        // Decode the encoded bytes - should be byte-identical to source.
        const decoded: Wave = try loadFromMemory(&ws, ta, ".wav", wav_bytes);
        defer unload(&ws, decoded);
        try expectEqual(orig.frameCount, decoded.frameCount);
        try expectEqual(orig.sampleRate, decoded.sampleRate);
        try expectEqual(orig.channels, decoded.channels);
        try expectEqual(orig.sampleSize, decoded.sampleSize);
    }

    test "exportToMemory: rejects invalid wave" {
        const ta: Allocator = std.testing.allocator;
        try expectError(error.InvalidWave, exportToMemory(ta, .{}));
    }
};

pub const composer = struct {
    /// Default output format for composer-generated waves.  Picked
    /// to match the most common SFX target.
    pub const default_sample_rate: u32 = 44100;
    pub const default_sample_size: u32 = 16;
    pub const default_channels: u32 = 1;

    /// Available waveform shapes.  Each is a closed-form function
    /// of phase ∈ [0, 1).
    pub const Shape = enum {
        sine,
        square,
        triangle,
        sawtooth,

        /// Sample value at phase, normalized to [-1, 1].
        pub fn sample(self: Shape, phase: f32) f32 {
            return switch (self) {
                .sine => @sin(phase * tau),
                .square => if (phase < 0.5) 1.0 else -1.0,
                .triangle => blk: {
                    if (phase < 0.5) {
                        break :blk -1.0 + 4.0 * phase;
                    }
                    break :blk 3.0 - 4.0 * phase;
                },
                .sawtooth => 2.0 * phase - 1.0,
            };
        }
    };

    /// ADSR envelope parameters.  Times in milliseconds; sustain in
    /// `[0, 1]` (relative to peak).  All-zero defaults yield a
    /// rectangular envelope (no shaping).
    pub const Envelope = struct {
        attack_ms: f32 = 0.0,
        decay_ms: f32 = 0.0,
        sustain_level: f32 = 1.0,
        release_ms: f32 = 0.0,

        /// Compute the envelope's gain at the given offset (in
        /// frames).  `total_frames` is the full tone duration.
        fn gainAt(
            self: Envelope,
            frame: u32,
            total_frames: u32,
            sample_rate: u32,
        ) f32 {
            if (total_frames == 0) {
                return 0.0;
            }
            const t_ms: f32 = float(frame) * 1000.0 /
                float(sample_rate);
            const total_ms: f32 = float(total_frames) * 1000.0 /
                float(sample_rate);
            const release_start: f32 = total_ms - self.release_ms;

            // Attack: 0 → 1 over attack_ms.
            if (t_ms < self.attack_ms) {
                if (self.attack_ms == 0.0) {
                    return 1.0;
                }
                return t_ms / self.attack_ms;
            }
            // Decay: 1 → sustain_level over decay_ms after attack.
            if (t_ms < self.attack_ms + self.decay_ms) {
                if (self.decay_ms == 0.0) {
                    return self.sustain_level;
                }
                const phase: f32 = (t_ms - self.attack_ms) / self.decay_ms;
                return 1.0 + phase * (self.sustain_level - 1.0);
            }
            // Release: sustain_level → 0 over release_ms before end.
            if (t_ms >= release_start) {
                if (self.release_ms == 0.0) {
                    return 0.0;
                }
                const phase: f32 = (t_ms - release_start) / self.release_ms;
                return self.sustain_level * (1.0 - phase);
            }
            // Sustain.
            return self.sustain_level;
        }
    };

    /// Tone-generation options.  Picked so the common case (a
    /// 100-ms beep) is `.{ .frequency_hz = 880.0, .duration_ms =
    /// 100 }` with zero further config.
    pub const ToneOptions = struct {
        frequency_hz: f32,
        duration_ms: u32,
        shape: Shape = .sine,
        amplitude: f32 = 0.5,
        sample_rate: u32 = default_sample_rate,
        envelope: Envelope = .{},
    };

    /// Generate a tone Wave according to `opts`.  Output is 16-bit
    /// signed PCM mono at the requested sample rate; caller `unload`s.
    /// The envelope shapes amplitude over the whole duration; with
    /// `.attack_ms = 0` and `.release_ms = 0` the output is a hard-
    /// gated tone (audible click at start/end at higher amplitudes).
    /// Sub-millisecond attack/release is fine - even 2 ms is enough
    /// to suppress the click.
    /// Synthesize a single tone Wave at the given frequency / shape /
    /// duration.  The result is 16-bit signed PCM at `opts.sample_rate`,
    /// `default_channels` (1) channels.  Caller owns the Wave and must
    /// `waves.unload(waves_state, w)` to free it.
    /// Reads `opts` (frequency, shape, duration, envelope, amplitude,
    /// sample_rate).  Mutates `gpa.*` (allocates the PCM buffer +
    /// registers it into `waves_state`).  Mutates `waves_state.entries`
    /// (registers the new buffer for tracking, so a later
    /// `waves.unload` can find + free it).
    pub fn tone(
        waves_state: *waves.AllocTable,
        gpa: Allocator,
        opts: ToneOptions,
    ) !Wave {
        const sr: u32 = opts.sample_rate;
        const total_frames: u32 = (sr * opts.duration_ms + 500) / 1000;
        const byte_len: usize = @as(usize, total_frames) * 2;
        const out: []u8 = try gpa.alloc(u8, byte_len);
        errdefer gpa.free(out);

        const phase_step: f32 = opts.frequency_hz / float(sr);
        var phase: f32 = 0.0;
        for (0..total_frames) |f| {
            const env: f32 = opts.envelope.gainAt(@intCast(f), total_frames, sr);
            const raw: f32 = opts.shape.sample(phase) * opts.amplitude * env;
            const v_i: i32 = @round(raw * 32767.0);
            const clipped: i16 = @intCast(clamp(v_i, -32768, 32767));
            std.mem.writeInt(i16, out[f * 2 ..][0..2], clipped, .little);
            phase += phase_step;
            if (phase >= 1.0) {
                phase -= 1.0;
            }
        }

        const w: Wave = .{
            .frameCount = total_frames,
            .sampleRate = sr,
            .sampleSize = default_sample_size,
            .channels = default_channels,
            .data = @as(?*anyopaque, @ptrCast(out.ptr)),
        };
        waves_state.put(@ptrCast(out.ptr), gpa);
        return w;
    }

    /// Silence-Wave generator.  Useful as a sequencer pad or as a
    /// time-aligned spacer between tones.  Caller owns the Wave;
    /// `waves.unload(waves_state, w)` to free it.
    /// Reads `duration_ms`, `sample_rate`.  Mutates `gpa.*`
    /// (allocates the zeroed PCM buffer).  Mutates
    /// `waves_state.entries` (registers the new buffer for
    /// tracking).
    pub fn silence(
        waves_state: *waves.AllocTable,
        gpa: Allocator,
        duration_ms: u32,
        sample_rate: u32,
    ) !Wave {
        const total_frames: u32 = (sample_rate * duration_ms + 500) / 1000;
        const byte_len: usize = @as(usize, total_frames) * 2;
        const out: []u8 = try gpa.alloc(u8, byte_len);
        @memset(out, 0);
        const w: Wave = .{
            .frameCount = total_frames,
            .sampleRate = sample_rate,
            .sampleSize = default_sample_size,
            .channels = default_channels,
            .data = @as(?*anyopaque, @ptrCast(out.ptr)),
        };
        waves_state.put(@ptrCast(out.ptr), gpa);
        return w;
    }

    /// One placement entry inside a Sequence: place `wave` starting
    /// at `start_frame` (offset from the sequence origin).  Multiple
    /// placements at overlapping time ranges sum sample-wise, with
    /// hard clipping.
    pub const Placement = struct {
        wave: Wave,
        start_frame: u32,
    };

    /// Multi-wave sequencer / mixer.  Build with `init`, append
    /// placements with `add`, finalize with `finalize`.
    /// All placements must share the same sample_rate + channels;
    /// `add` doesn't validate (caller's contract).  Finalize emits
    /// a single Wave with frameCount = max(start_frame +
    /// wave.frameCount) across all placements.
    pub const Sequence = struct {
        placements: ArrayList(Placement),
        gpa: Allocator,
        waves_state: *waves.AllocTable,
        sample_rate: u32,
        channels: u32,

        pub fn init(
            waves_state: *waves.AllocTable,
            gpa: Allocator,
            sample_rate: u32,
            channels: u32,
        ) Sequence {
            return .{
                .placements = .empty,
                .gpa = gpa,
                .waves_state = waves_state,
                .sample_rate = sample_rate,
                .channels = channels,
            };
        }

        pub fn deinit(self: *Sequence) void {
            self.placements.deinit(self.gpa);
        }

        /// Schedule `wave` to play starting at `start_frame`.
        /// The Sequence does NOT take ownership of the wave
        /// caller is responsible for keeping it alive until
        /// finalize.
        pub fn add(
            self: *Sequence,
            wave: Wave,
            start_frame: u32,
        ) !void {
            try self.placements.append(self.gpa, .{ .wave = wave, .start_frame = start_frame });
        }

        /// Mix all placements into a single Wave at the sequence's
        /// sample_rate + channels.  Output bit depth is 16-bit signed
        /// PCM.  Mixing is sample-wise sum with hard clipping at
        /// the int16 range.
        /// Note: cross-rate / cross-bit-depth mixing is NOT
        /// supported by this minimal mixer - placements must share
        /// the sequence's format (16-bit PCM at the same rate).
        /// Use `waves.format` to convert before adding.
        /// Reads `self.placements` (frame ranges + wave data),
        /// `self.sample_rate`, `self.channels`.  Mutates
        /// `self.gpa.*` (allocates the mixed buffer).  Mutates
        /// `self.waves_state.entries` (registers the new buffer
        /// for tracking).
        pub fn finalize(self: Sequence) !Wave {
            // Compute total frame extent.
            var max_end: u32 = 0;
            for (self.placements.items) |p| {
                const end: u32 = p.start_frame + p.wave.frameCount;
                if (end > max_end) {
                    max_end = end;
                }
            }
            if (max_end == 0) {
                return silence(self.waves_state, self.gpa, 0, self.sample_rate);
            }
            const byte_len: usize = @as(usize, max_end) * self.channels * 2;
            const out = try self.gpa.alloc(u8, byte_len);
            @memset(out, 0);
            errdefer self.gpa.free(out);

            // Per-frame accumulator.  i32 to give us headroom for
            // overlapping placements before we clip.
            const accum = try self.gpa.alloc(i32, max_end * self.channels);
            defer self.gpa.free(accum);
            @memset(accum, 0);

            for (self.placements.items) |p| {
                if (p.wave.data == null) {
                    continue;
                }
                const src_ptr: [*]const u8 = @ptrCast(p.wave.data.?);
                const fc: u32 = p.wave.frameCount;
                const ch: u32 = p.wave.channels;
                for (0..fc) |f| {
                    for (0..ch) |c| {
                        const out_frame: u32 = p.start_frame + @as(u32, @intCast(f));
                        if (out_frame >= max_end) {
                            break;
                        }
                        const src_off: usize = (f * ch + c) * 2;
                        const v: i16 = std.mem.readInt(i16, src_ptr[src_off..][0..2], .little);
                        const out_idx: usize = out_frame * self.channels + c;
                        accum[out_idx] += v;
                    }
                }
            }

            // Clip + serialize.
            for (0..accum.len) |i| {
                const v: i32 = accum[i];
                const clipped: i16 = @intCast(clamp(v, -32768, 32767));
                std.mem.writeInt(i16, out[i * 2 ..][0..2], clipped, .little);
            }

            const w: Wave = .{
                .frameCount = max_end,
                .sampleRate = self.sample_rate,
                .sampleSize = default_sample_size,
                .channels = self.channels,
                .data = @as(?*anyopaque, @ptrCast(out.ptr)),
            };
            self.waves_state.put(@ptrCast(out.ptr), self.gpa);
            return w;
        }
    };

    // ---- Tests
    test "Shape.sample: sine zero crossings" {
        try expectApproxEqAbs(@as(f32, 0.0), Shape.sine.sample(0.0), 0.001);
        try expectApproxEqAbs(@as(f32, 1.0), Shape.sine.sample(0.25), 0.001);
        try expectApproxEqAbs(@as(f32, 0.0), Shape.sine.sample(0.5), 0.001);
        try expectApproxEqAbs(@as(f32, -1.0), Shape.sine.sample(0.75), 0.001);
    }

    test "Shape.sample: square ±1" {
        try expectEqual(@as(f32, 1.0), Shape.square.sample(0.0));
        try expectEqual(@as(f32, 1.0), Shape.square.sample(0.499));
        try expectEqual(@as(f32, -1.0), Shape.square.sample(0.5));
        try expectEqual(@as(f32, -1.0), Shape.square.sample(0.999));
    }

    test "Shape.sample: triangle peaks" {
        try expectApproxEqAbs(@as(f32, -1.0), Shape.triangle.sample(0.0), 0.001);
        try expectApproxEqAbs(@as(f32, 1.0), Shape.triangle.sample(0.5), 0.001);
    }

    test "Envelope.gainAt: rectangular (all zero) → unity" {
        const env: Envelope = .{};
        try expectApproxEqAbs(@as(f32, 1.0), env.gainAt(0, 100, 44100), 0.001);
        try expectApproxEqAbs(@as(f32, 1.0), env.gainAt(50, 100, 44100), 0.001);
    }

    test "Envelope.gainAt: 10 ms attack ramps 0 → 1" {
        const env: Envelope = .{ .attack_ms = 10.0 };
        // At 5 ms (frame 220 of 44100 Hz) we should be at 0.5.
        try expectApproxEqAbs(@as(f32, 0.5), env.gainAt(220, 4410, 44100), 0.05);
    }

    test "tone: A440 sine for 50 ms" {
        const ta: Allocator = std.testing.allocator;
        var ws: waves.AllocTable = .{};
        const w: Wave = try tone(&ws, ta, .{ .frequency_hz = 440.0, .duration_ms = 50 });
        defer waves.unload(&ws, w);
        try expect(waves.isValid(w));
        try expectEqual(@as(u32, 44100), w.sampleRate);
        // 50 ms * 44.1 frames/ms = 2205 frames
        try expectEqual(@as(u32, 2205), w.frameCount);
    }

    test "tone with envelope: starts and ends at zero" {
        const ta: Allocator = std.testing.allocator;
        var ws: waves.AllocTable = .{};
        const w: Wave = try tone(&ws, ta, .{
            .frequency_hz = 440.0,
            .duration_ms = 100,
            .envelope = .{ .attack_ms = 5.0, .release_ms = 5.0 },
        });
        defer waves.unload(&ws, w);
        const ptr: [*]const u8 = @ptrCast(w.data.?);
        const first: i16 = std.mem.readInt(i16, ptr[0..2], .little);
        try expectEqual(@as(i16, 0), first);
        // Last frame: 4410 - 1 = 4409 → byte offset 8818.
        const last_off: usize = (w.frameCount - 1) * 2;
        const last: i16 = std.mem.readInt(i16, ptr[last_off..][0..2], .little);
        // With release ramp, the last frame is at gain = 0, so output ≈ 0.
        try expect(@abs(last) < 100);
    }

    test "silence: all zeros" {
        const ta: Allocator = std.testing.allocator;
        var ws: waves.AllocTable = .{};
        const w: Wave = try silence(&ws, ta, 100, 44100);
        defer waves.unload(&ws, w);
        const ptr: [*]const u8 = @ptrCast(w.data.?);
        const byte_len: usize = w.frameCount * 2;
        for (0..byte_len) |i| {
            try expectEqual(@as(u8, 0), ptr[i]);
        }
    }

    test "Sequence: empty produces empty" {
        const ta: Allocator = std.testing.allocator;
        var ws: waves.AllocTable = .{};
        var seq: Sequence = .init(&ws, ta, 44100, 1);
        defer seq.deinit();
        const out: Wave = try seq.finalize();
        defer waves.unload(&ws, out);
        try expectEqual(@as(u32, 0), out.frameCount);
    }

    test "Sequence: two non-overlapping tones concatenate" {
        const ta: Allocator = std.testing.allocator;
        var ws: waves.AllocTable = .{};
        const t1: Wave = try tone(&ws, ta, .{ .frequency_hz = 440.0, .duration_ms = 100 });
        defer waves.unload(&ws, t1);
        const t2: Wave = try tone(&ws, ta, .{ .frequency_hz = 880.0, .duration_ms = 100 });
        defer waves.unload(&ws, t2);
        var seq: Sequence = .init(&ws, ta, 44100, 1);
        defer seq.deinit();
        try seq.add(t1, 0);
        try seq.add(t2, 4410);
        const out: Wave = try seq.finalize();
        defer waves.unload(&ws, out);
        try expectEqual(@as(u32, 4410 + 4410), out.frameCount);
    }

    test "Sequence: overlapping placements sum samples" {
        const ta: Allocator = std.testing.allocator;
        var ws: waves.AllocTable = .{};
        // Two short, identical waves at the same offset → sum is
        // ~2× each sample, clipped at i16 range.
        const t1: Wave = try tone(&ws, ta, .{ .frequency_hz = 440.0, .duration_ms = 50 });
        defer waves.unload(&ws, t1);
        const t2: Wave = try tone(&ws, ta, .{ .frequency_hz = 440.0, .duration_ms = 50 });
        defer waves.unload(&ws, t2);
        var seq: Sequence = .init(&ws, ta, 44100, 1);
        defer seq.deinit();
        try seq.add(t1, 0);
        try seq.add(t2, 0);
        const out: Wave = try seq.finalize();
        defer waves.unload(&ws, out);
        try expectEqual(t1.frameCount, out.frameCount);
        // First frame's sample of a sine wave is exactly 0 (sin(0)
        // = 0), so 0 + 0 = 0.
        const ptr: [*]const u8 = @ptrCast(out.data.?);
        const first: i16 = std.mem.readInt(i16, ptr[0..2], .little);
        try expectEqual(@as(i16, 0), first);
    }
};

pub const audio_device = struct {
    const is_wasm: bool = builtin.target.cpu.arch.isWasm();

    /// Singleton state.  Populated by `init()`; cleared by `close()`.
    /// Not thread-safe - wasm32-wasi is single-threaded by design.
    pub const AudioDeviceState = struct {
        ctx_id: web.audio.ContextId = 0,
        ready: bool = false,
        /// Cached at init; used by the pipeline so we don't ask the
        /// JS bridge every time we resample a Sound.
        sample_rate: u32 = 0,
    };

    /// Initialize the audio device.  Must be called once before any
    /// Wave/Sound/Music op.  Idempotent - calling twice is harmless;
    /// the second call is a no-op.
    /// On wasm: creates a real Web Audio AudioContext.  Browsers
    /// start contexts suspended; the first user gesture (click /
    /// touch / keydown) auto-resumes via `resumeFromGesture` (Phase
    /// 4).  Until then, sounds queue silently.
    /// On host: marks ready with sample_rate = 48000 and a synthetic
    /// ctx_id so wrappers don't bail with the "no context" path.
    pub fn init(state: *AudioDeviceState) void {
        if (state.ready) {
            return;
        }
        if (comptime !is_wasm) {
            // Host stub: synthesize a non-zero ctx_id so wrappers
            // exercise their happy path.  No real Web Audio.
            state.* = .{
                .ctx_id = 1,
                .ready = true,
                .sample_rate = 48000,
            };
            return;
        }
        const id: web.audio.ContextId = web.audio.createContext();
        if (id == 0) {
            // AudioContext creation failed (very old browser / Web
            // Audio disabled).  Stay un-ready; callers see all
            // sound ops as no-ops via `isReady` checks.
            return;
        }
        const sr: f32 = web.audio.getSampleRate(id);
        state.* = .{
            .ctx_id = id,
            .ready = true,
            .sample_rate = @trunc(sr),
        };
    }

    /// Tear down the audio device.  Stops all in-flight sounds.
    /// Idempotent.  After `close()`, callers must `init()` again
    /// before any further sound ops.
    pub fn close(state: *AudioDeviceState) void {
        if (!state.ready) {
            return;
        }
        if (comptime is_wasm) {
            web.audio.closeContext(state.ctx_id);
        }
        state.* = .{};
    }

    /// True iff the device is initialized and ready to accept Sound
    /// ops.  Higher-level wrappers gate every op on this.
    pub fn isReady(state: *const AudioDeviceState) bool {
        return state.ready;
    }

    /// Get the master volume (1.0 = unity).  Returns 1.0 if the
    /// device isn't ready.
    pub fn getMasterVolume(state: *const AudioDeviceState) f32 {
        if (!state.ready) {
            return 1.0;
        }
        if (comptime !is_wasm) {
            return 1.0;
        }
        return web.audio.getMasterVolume(state.ctx_id);
    }

    /// Set the master volume.  Clamped to `[0, 10]` (matches the
    /// underlying `web.audio.setMasterVolume`).  Silent if the
    /// device isn't ready.
    pub fn setMasterVolume(
        state: *const AudioDeviceState,
        volume: f32,
    ) void {
        if (!state.ready) {
            return;
        }
        if (comptime is_wasm) {
            web.audio.setMasterVolume(state.ctx_id, volume);
        }
    }

    /// Internal accessors for sibling namespaces.  Not part of the
    /// raylib parity surface.
    pub fn getContextId(state: *const AudioDeviceState) web.audio.ContextId {
        return state.ctx_id;
    }
    pub fn getSampleRate(state: *const AudioDeviceState) u32 {
        return state.sample_rate;
    }

    /// Try to resume a suspended AudioContext.  Browsers reject
    /// the resume unless called from a user-gesture handler; the
    /// runtime wires this into the input layer.  Direct callers can
    /// also invoke it from their own click handler.
    pub fn resumeFromGesture(state: *const AudioDeviceState) void {
        if (!state.ready) {
            return;
        }
        if (comptime is_wasm) {
            web.audio.resumeContext(state.ctx_id);
        }
    }

    // ---- Tests
    test "init / close / isReady on host" {
        var ad: AudioDeviceState = .{};
        try expect(!isReady(&ad));
        init(&ad);
        try expect(isReady(&ad));
        // Idempotent: second init is a no-op.
        init(&ad);
        try expect(isReady(&ad));
        close(&ad);
        try expect(!isReady(&ad));
        // Idempotent: second close is a no-op.
        close(&ad);
        try expect(!isReady(&ad));
    }

    test "getSampleRate returns 48000 on host after init" {
        var ad: AudioDeviceState = .{};
        init(&ad);
        defer close(&ad);
        try expectEqual(@as(u32, 48000), getSampleRate(&ad));
    }

    test "getMasterVolume returns 1.0 on host" {
        var ad: AudioDeviceState = .{};
        try expectEqual(@as(f32, 1.0), getMasterVolume(&ad));
        init(&ad);
        defer close(&ad);
        try expectEqual(@as(f32, 1.0), getMasterVolume(&ad));
    }

    test "setMasterVolume on host is silent" {
        var ad: AudioDeviceState = .{};
        // Must not panic regardless of device state or input.
        setMasterVolume(&ad, 0.5);
        init(&ad);
        defer close(&ad);
        setMasterVolume(&ad, 0.5);
        setMasterVolume(&ad, -1.0);
        setMasterVolume(&ad, 100.0);
    }

    test "getContextId is non-zero after init on host" {
        var ad: AudioDeviceState = .{};
        init(&ad);
        defer close(&ad);
        try expect(getContextId(&ad) != 0);
    }

    test "resumeFromGesture is silent (host stub)" {
        var ad: AudioDeviceState = .{};
        // No-op when not ready.
        resumeFromGesture(&ad);
        init(&ad);
        defer close(&ad);
        // Also no-op on host (is_wasm = false branch).
        resumeFromGesture(&ad);
    }
};

pub const streams = struct {
    const RecycleRingSize: usize = 8;

    /// Side-table entry for an AudioStream.  Tracks scheduling
    /// state and the recycle ring of recently-uploaded buffers.
    const StreamEntry = struct {
        ctx_id: web.audio.ContextId = 0,
        sample_rate: u32 = 0,
        sample_size: u32 = 0,
        channels: u32 = 0,
        /// AudioContext-time of the next scheduled chunk's start.
        /// Initialized to `getCurrentTime + lookahead` on first
        /// update.  After each scheduling: head += chunk_duration.
        head_time: f64 = 0.0,
        /// Lookahead buffer (seconds).  Picked at init to absorb
        /// frame-time jitter - a typical browser frame is ~16 ms,
        /// so 30 ms gives 2 frames of slack.
        lookahead_s: f64 = 0.030,
        /// Whether `update` has been called at least once (so we
        /// know to seed `head_time` from `getCurrentTime`).
        primed: bool = false,
        /// Whether playback is paused (skip scheduling).
        paused: bool = false,
        /// Per-stream playback parameters.  Applied to every chunk.
        volume: f32 = 1.0,
        pitch: f32 = 1.0,
        pan: f32 = 0.0,
        /// Recycle ring: bounded queue of recently-scheduled
        /// buffers.  When full, the oldest gets unloaded - Web
        /// Audio holds a reference until the source finishes, so
        /// dropping ours is safe even mid-play.
        recycle: [RecycleRingSize]web.audio.BufferId = @splat(0),
        recycle_head: u8 = 0,

        in_use: bool = false,
    };

    pub const StreamTable = struct {
        const Capacity = 32;
        entries: [Capacity]StreamEntry = @splat(.{}),

        fn allocate(self: *StreamTable) ?u32 {
            for (&self.entries, 0..) |*e, i| {
                if (!e.in_use) {
                    e.* = .{ .in_use = true };
                    return @intCast(i + 1);
                }
            }
            return null;
        }

        fn get(self: *StreamTable, slot: u32) ?*StreamEntry {
            if (slot == 0 or slot > Capacity) {
                return null;
            }
            const e: *StreamEntry = &self.entries[slot - 1];
            if (!e.in_use) {
                return null;
            }
            return e;
        }

        fn getConst(self: *const StreamTable, slot: u32) ?*const StreamEntry {
            if (slot == 0 or slot > Capacity) {
                return null;
            }
            const e: *const StreamEntry = &self.entries[slot - 1];
            if (!e.in_use) {
                return null;
            }
            return e;
        }

        fn release(self: *StreamTable, slot: u32) void {
            if (slot == 0 or slot > Capacity) {
                return;
            }
            self.entries[slot - 1] = .{};
        }
    };

    /// Create a new AudioStream at `(sample_rate, sample_size,
    /// channels)`.  `sample_size` is informational on the web
    /// callers always push f32 frames via `update`.  Channel
    /// counts other than 1 (mono) and 2 (stereo) are accepted
    /// but get downmixed to stereo on upload.
    /// Reads `device.is_ready` (early-out on closed device),
    /// `device.ctx_id` (Web Audio context for chunk uploads).
    /// Mutates `state.entries` (allocates one slot on success).
    /// Returns an empty AudioStream (`.buffer = null`) if the
    /// device isn't ready or the table is full.
    pub fn load(
        state: *StreamTable,
        device: *const audio_device.AudioDeviceState,
        sample_rate: u32,
        sample_size: u32,
        channels: u32,
    ) AudioStream {
        if (!audio_device.isReady(device)) {
            return .{};
        }
        const slot: u32 = state.allocate() orelse return .{};
        const e: *StreamEntry = state.get(slot).?;
        e.* = .{
            .in_use = true,
            .ctx_id = audio_device.getContextId(device),
            .sample_rate = sample_rate,
            .sample_size = sample_size,
            .channels = channels,
        };
        return AudioStream{
            .buffer = @ptrFromInt(@as(usize, slot)),
            .processor = null,
            .sampleRate = sample_rate,
            .sampleSize = sample_size,
            .channels = channels,
        };
    }

    /// Free the stream's resources.  Stops any in-flight playback
    /// (Web Audio holds buffer references via source nodes anyway,
    /// so already-scheduled chunks may still play out).  Idempotent.
    pub fn unload(
        state: *StreamTable,
        stream: AudioStream,
    ) void {
        const slot: u32 = streamSlot(state, stream) orelse return;
        const e: *StreamEntry = state.get(slot) orelse return;
        // Drop all recycled buffers.  In-flight source nodes hold
        // their own refs; any not-yet-played chunks will silently
        // disappear when their start time passes.
        for (e.recycle) |buf_id| {
            if (buf_id != 0) {
                web.audio.unloadAudioBuffer(e.ctx_id, buf_id);
            }
        }
        state.release(slot);
    }

    /// Whether the stream has slot space for another chunk
    /// scheduled.  Always returns true on the web platform - we
    /// never run out of slots.  Present for raylib parity.
    pub fn isValid(
        state: *const StreamTable,
        stream: AudioStream,
    ) bool {
        return streamSlot(state, stream) != null;
    }

    /// Whether the next chunk slot is "free" (head time is within
    /// `lookahead` of the current wall-clock).  Callers should
    /// produce + push more samples when this is true.
    /// On host (no real time advancing), always returns true
    /// the host stub doesn't model scheduling latency.
    pub fn isProcessed(
        state: *const StreamTable,
        stream: AudioStream,
    ) bool {
        const slot: u32 = streamSlot(state, stream) orelse return false;
        const e: *const StreamEntry = state.getConst(slot) orelse return false;
        if (!e.primed) {
            return true;
        }
        const now: f64 = web.audio.getCurrentTime(e.ctx_id);
        // Head is "free" when the wall-clock has caught up to
        // within one lookahead window of the head.  Equivalently:
        // we're within lookahead of needing more data.
        return e.head_time - now <= e.lookahead_s;
    }

    /// Push `frames` (interleaved f32 at the stream's sample_rate
    /// and channels) into the playback queue.  Schedules them to
    /// start at the stream's head time.
    /// Allocation: each `update` call allocates a Web Audio
    /// AudioBuffer.  Web Audio recycles its own buffer pool
    /// internally; we hold the last `RecycleRingSize` BufferIds
    /// and unload them in FIFO order so the JS side doesn't pile
    /// up garbage.  In the steady state this is a fixed-size
    /// working set.
    /// Silent if the stream isn't valid or the device isn't ready.
    pub fn update(
        state: *StreamTable,
        stream: AudioStream,
        frames: []const f32,
    ) void {
        const slot: u32 = streamSlot(state, stream) orelse return;
        const e: *StreamEntry = state.get(slot) orelse return;
        if (e.paused) {
            return;
        }
        if (frames.len == 0) {
            return;
        }
        const frame_count: u32 = @intCast(frames.len / e.channels);
        if (frame_count == 0) {
            return;
        }

        const buf_id: web.audio.BufferId = web.audio.loadAudioBuffer(
            e.ctx_id,
            e.sample_rate,
            e.channels,
            frame_count,
            frames,
        );
        if (buf_id == 0 and comptime builtin.target.cpu.arch.isWasm()) {
            return;
        }

        // Prime head_time on first push.  Add lookahead so the
        // first chunk has slack to schedule before its start time.
        if (!e.primed) {
            const now: f64 = web.audio.getCurrentTime(e.ctx_id);
            e.head_time = now + e.lookahead_s;
            e.primed = true;
        }

        const chunk_duration_s: f64 = float64(frame_count) /
            float64(e.sample_rate);

        // Schedule.  Returned source-id we discard - the source
        // self-evicts on `onended`.  Loss-of-id matters only for
        // explicit stop, which AudioStream callers don't do per-chunk.
        _ = web.audio.playBufferAt(
            e.ctx_id,
            buf_id,
            e.volume,
            e.pitch,
            e.pan,
            e.head_time,
        );
        e.head_time += chunk_duration_s;

        // Recycle: free the oldest entry, replace with this one.
        const old: web.audio.BufferId = e.recycle[e.recycle_head];
        if (old != 0) {
            web.audio.unloadAudioBuffer(e.ctx_id, old);
        }
        e.recycle[e.recycle_head] = buf_id;
        e.recycle_head = (e.recycle_head + 1) % @as(u8, @intCast(RecycleRingSize));
    }

    /// Pause the stream.  In-flight scheduled chunks continue
    /// playing (they're already in the browser's audio graph);
    /// new `update` calls become no-ops.
    pub fn pause(
        state: *StreamTable,
        stream: AudioStream,
    ) void {
        const slot: u32 = streamSlot(state, stream) orelse return;
        const e: *StreamEntry = state.get(slot) orelse return;
        e.paused = true;
    }

    /// Explicitly start the stream.  Streams already auto-start on
    /// their first `update` call, so this is mostly a no-op for
    /// already-streaming streams; for paused streams it has the
    /// same effect as `resumeStream`.  Provided for raylib parity.
    pub fn play(
        state: *StreamTable,
        stream: AudioStream,
    ) void {
        const slot: u32 = streamSlot(state, stream) orelse return;
        const e: *StreamEntry = state.get(slot) orelse return;
        if (e.paused) {
            e.paused = false;
            e.primed = false;
        }
    }

    /// Stop the stream and discard any in-flight scheduled chunks
    /// the bridge knows about.  Already-scheduled chunks in Web
    /// Audio's graph still play out (we have no per-chunk SourceId
    /// to cancel them).  After `stop`, the next `update` call
    /// re-primes head_time from the current wall-clock.
    pub fn stop(
        state: *StreamTable,
        stream: AudioStream,
    ) void {
        const slot: u32 = streamSlot(state, stream) orelse return;
        const e: *StreamEntry = state.get(slot) orelse return;
        // Drop any recycled buffers proactively.  In-flight source
        // nodes hold their own refs via Web Audio.
        for (&e.recycle) |*buf_id| {
            if (buf_id.* != 0) {
                web.audio.unloadAudioBuffer(e.ctx_id, buf_id.*);
                buf_id.* = 0;
            }
        }
        e.recycle_head = 0;
        e.primed = false;
        e.paused = false;
        e.head_time = 0.0;
    }

    /// Resume a paused stream.  Resets `primed` so the next
    /// `update` re-seeds `head_time` from the current wall-clock,
    /// avoiding a long catch-up if pause was held for a while.
    pub fn resumeStream(
        state: *StreamTable,
        stream: AudioStream,
    ) void {
        const slot: u32 = streamSlot(state, stream) orelse return;
        const e: *StreamEntry = state.get(slot) orelse return;
        if (!e.paused) {
            return;
        }
        e.paused = false;
        e.primed = false;
    }

    /// Whether the stream is currently scheduling new audio.
    /// True iff `update` was called at least once and the stream
    /// isn't paused.
    pub fn isPlaying(
        state: *const StreamTable,
        stream: AudioStream,
    ) bool {
        const slot: u32 = streamSlot(state, stream) orelse return false;
        const e: *const StreamEntry = state.getConst(slot) orelse return false;
        return e.primed and !e.paused;
    }

    /// Set per-stream volume.  Applied to the next chunk pushed
    /// via `update`; in-flight chunks keep their original gain.
    /// Clamped to `[0, 10]`.
    pub fn setVolume(
        state: *StreamTable,
        stream: AudioStream,
        volume: f32,
    ) void {
        const slot: u32 = streamSlot(state, stream) orelse return;
        const e: *StreamEntry = state.get(slot) orelse return;
        e.volume = clamp(volume, 0.0, 10.0);
    }

    /// Set per-stream pitch.  Clamped to `[0.0625, 16.0]`.
    pub fn setPitch(
        state: *StreamTable,
        stream: AudioStream,
        pitch: f32,
    ) void {
        const slot: u32 = streamSlot(state, stream) orelse return;
        const e: *StreamEntry = state.get(slot) orelse return;
        e.pitch = clamp(pitch, 0.0625, 16.0);
    }

    /// Set per-stream pan.  Clamped to `[-1, 1]`.
    pub fn setPan(
        state: *StreamTable,
        stream: AudioStream,
        pan: f32,
    ) void {
        const slot: u32 = streamSlot(state, stream) orelse return;
        const e: *StreamEntry = state.get(slot) orelse return;
        e.pan = clamp(pan, -1.0, 1.0);
    }

    fn streamSlot(
        state: *const StreamTable,
        stream: AudioStream,
    ) ?u32 {
        _ = state;
        const ptr: *types.rAudioBuffer = stream.buffer orelse return null;
        const as_int: usize = @intFromPtr(ptr);
        if (as_int == 0 or as_int > StreamTable.Capacity) {
            return null;
        }
        return @intCast(as_int);
    }

    // ---- Tests
    test "load: empty stream when device not ready" {
        var dev: audio_device.AudioDeviceState = .{};
        // intentionally NOT calling init - testing the not-ready early-out.
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        try expect(!isValid(&st, s));
    }

    test "load + unload lifecycle" {
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        defer unload(&st, s);
        try expect(isValid(&st, s));
        try expectEqual(@as(u32, 48000), s.sampleRate);
        try expectEqual(@as(u32, 2), s.channels);
    }

    test "isPlaying false until first update" {
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        defer unload(&st, s);
        try expect(!isPlaying(&st, s));
    }

    test "update primes the stream and sets isPlaying true" {
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        defer unload(&st, s);
        // 480 frames at 48 kHz = 10 ms of stereo silence.
        const frames: [480 * 2]f32 = @splat(0.0);
        update(&st, s, &frames);
        try expect(isPlaying(&st, s));
    }

    test "isProcessed: true before priming, true on host after update" {
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        defer unload(&st, s);
        try expect(isProcessed(&st, s));
        const frames: [480 * 2]f32 = @splat(0.0);
        update(&st, s, &frames);
        // On host getCurrentTime returns 0; head_time = 0 + 0.030
        // + chunk_duration ≈ 0.040.  isProcessed: head - now <= 0.030
        // → false on this exact clock, so we don't assert true.
        // Instead just confirm the call doesn't panic.
        _ = isProcessed(&st, s);
    }

    test "pause / resume don't panic" {
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        defer unload(&st, s);
        const frames: [480 * 2]f32 = @splat(0.0);
        update(&st, s, &frames);
        pause(&st, s);
        try expect(!isPlaying(&st, s));
        // Updates while paused are silent.
        update(&st, s, &frames);
        resumeStream(&st, s);
        try expect(!isPlaying(&st, s)); // primed reset; needs another update
        update(&st, s, &frames);
        try expect(isPlaying(&st, s));
    }

    test "setVolume / setPitch / setPan: clamp + persist" {
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        defer unload(&st, s);
        const slot: u32 = streamSlot(&st, s).?;

        setVolume(&st, s, 0.5);
        try expectEqual(@as(f32, 0.5), st.get(slot).?.volume);
        setVolume(&st, s, -1.0);
        try expectEqual(@as(f32, 0.0), st.get(slot).?.volume);
        setPitch(&st, s, 2.0);
        try expectEqual(@as(f32, 2.0), st.get(slot).?.pitch);
        setPan(&st, s, -0.5);
        try expectEqual(@as(f32, -0.5), st.get(slot).?.pan);
    }

    test "update: empty frames is silent" {
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        defer unload(&st, s);
        const empty: []const f32 = &.{};
        update(&st, s, empty);
        try expect(!isPlaying(&st, s));
    }

    test "unload on default AudioStream is silent" {
        var st: StreamTable = .{};
        unload(&st, .{});
    }

    test "recycle ring fills and frees in FIFO order without panic" {
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        defer unload(&st, s);
        const frames: [480 * 2]f32 = @splat(0.0);
        // Push more chunks than the ring can hold (8) to force
        // recycle-eviction.  Each push advances head_time and
        // evicts one entry from the ring.
        for (0..16) |_| {
            update(&st, s, &frames);
        }
        try expect(isPlaying(&st, s));
    }

    test "play: idempotent on streaming stream; resumes paused stream" {
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        defer unload(&st, s);
        const frames: [480 * 2]f32 = @splat(0.0);
        update(&st, s, &frames);
        try expect(isPlaying(&st, s));
        // play on a streaming stream: no-op (still playing).
        play(&st, s);
        try expect(isPlaying(&st, s));
        // pause then play: equivalent to resumeStream.
        pause(&st, s);
        try expect(!isPlaying(&st, s));
        play(&st, s);
        try expect(!isPlaying(&st, s)); // primed = false, needs update
        update(&st, s, &frames);
        try expect(isPlaying(&st, s));
    }

    test "stop: drops recycle ring, resets primed/head" {
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var st: StreamTable = .{};
        const s: AudioStream = load(&st, &dev, 48000, 32, 2);
        defer unload(&st, s);
        const slot: u32 = streamSlot(&st, s).?;
        const frames: [480 * 2]f32 = @splat(0.0);
        for (0..3) |_| {
            update(&st, s, &frames);
        }
        try expect(isPlaying(&st, s));
        // After stop, primed = false, head_time = 0.
        stop(&st, s);
        try expect(!isPlaying(&st, s));
        try expectEqual(@as(f64, 0.0), st.get(slot).?.head_time);
        // Recycle ring entries cleared.
        for (st.get(slot).?.recycle) |b| {
            try expectEqual(@as(web.audio.BufferId, 0), b);
        }
        // Streamable again after stop.
        update(&st, s, &frames);
        try expect(isPlaying(&st, s));
    }
};

pub const music = struct {
    const MusicEntry = struct {
        ctx_id: web.audio.ContextId = 0,
        buffer_id: web.audio.BufferId = 0,
        source_id: web.audio.SourceId = 0,
        decode_id: web.audio.DecodeId = 0,
        /// AudioContext-time of the last `play` call.  Used by
        /// `getTimePlayed` to derive elapsed seconds.
        play_started_at: f64 = 0.0,
        /// Within-buffer offset for the next `play` call (in
        /// seconds).  Set by `seek`; consumed + reset by `play`.
        /// `getTimePlayed` adds this to `(now - play_started_at)`.
        play_offset: f64 = 0.0,
        /// Buffer duration in seconds (precomputed at load).
        duration_s: f64 = 0.0,
        /// Per-music playback parameters.  Apply on next play.
        volume: f32 = 1.0,
        pitch: f32 = 1.0,
        pan: f32 = 0.0,
        looping: bool = true,
        loaded: bool = false,
        in_use: bool = false,
    };

    pub const MusicTable = struct {
        const Capacity = 16;
        entries: [Capacity]MusicEntry = @splat(.{}),

        fn allocate(self: *MusicTable) ?u32 {
            for (&self.entries, 0..) |*e, i| {
                if (!e.in_use) {
                    e.* = .{ .in_use = true };
                    return @intCast(i + 1);
                }
            }
            return null;
        }

        fn get(self: *MusicTable, slot: u32) ?*MusicEntry {
            if (slot == 0 or slot > Capacity) {
                return null;
            }
            const e: *MusicEntry = &self.entries[slot - 1];
            if (!e.in_use) {
                return null;
            }
            return e;
        }

        fn getConst(self: *const MusicTable, slot: u32) ?*const MusicEntry {
            if (slot == 0 or slot > Capacity) {
                return null;
            }
            const e: *const MusicEntry = &self.entries[slot - 1];
            if (!e.in_use) {
                return null;
            }
            return e;
        }

        fn release(self: *MusicTable, slot: u32) void {
            if (slot == 0 or slot > Capacity) {
                return;
            }
            self.entries[slot - 1] = .{};
        }
    };

    /// Load a `Music` stream from in-memory bytes.  Dispatches via
    /// `Format.detect`: sync decode for WAV (full PCM upload as a
    /// Web Audio buffer), async for OGG (kicks off
    /// `web.audio.decodeOggBytes`; entry sits in "loading" until
    /// `isReady` promotes it).
    /// Reads `device.ctx_id` (sample-rate target + Web Audio context
    /// for buffer upload), `device.is_ready` (early-out on closed
    /// device).  Mutates `state.entries` (allocates one slot per
    /// successful load).  Mutates `waves_state.entries` for the WAV
    /// path (transient - the temporary `Wave` is unloaded before
    /// return).  Mutates `gpa.*` (decode buffers + resampled output
    /// + table allocations).
    /// `file_type` is currently unused - format dispatch uses byte
    /// sniffing.  Kept on the signature to mirror raylib's
    /// `LoadMusicStreamFromMemory` shape.
    pub fn loadFromMemory(
        state: *MusicTable,
        device: *const audio_device.AudioDeviceState,
        waves_state: *waves.AllocTable,
        gpa: Allocator,
        file_type: []const u8,
        bytes: []const u8,
    ) !Music {
        _ = file_type;
        if (!audio_device.isReady(device)) {
            return .{};
        }
        const fmt: codecs.audio.Format = codecs.audio.Format.detect(bytes) orelse return error.UnknownAudioFormat;
        switch (fmt) {
            .wav => {
                // Sync path: decode → upload → table entry.
                const w: Wave = try waves.loadFromMemory(waves_state, gpa, ".wav", bytes);
                defer waves.unload(waves_state, w);

                const cw: codecs.audio.CanonicalWave = codecs.audio.canonicalFromWave(w);
                const stereo_f32: []f32 = try codecs.audio.wav.toFloat32Stereo(gpa, cw);
                defer gpa.free(stereo_f32);
                const target_rate: u32 = audio_device.getSampleRate(device);
                const resampled: []f32 = if (target_rate == w.sampleRate)
                    try gpa.dupe(f32, stereo_f32)
                else
                    try codecs.audio.wav.resampleLinear(gpa, stereo_f32, 2, w.sampleRate, target_rate);
                defer gpa.free(resampled);

                const ctx_id: web.audio.ContextId = audio_device.getContextId(device);
                const out_frames: u32 = @intCast(resampled.len / 2);
                const buf_id: web.audio.BufferId = web.audio.loadAudioBuffer(
                    ctx_id,
                    target_rate,
                    2,
                    out_frames,
                    resampled,
                );

                const slot: u32 = state.allocate() orelse {
                    if (comptime builtin.target.cpu.arch.isWasm()) {
                        web.audio.unloadAudioBuffer(ctx_id, buf_id);
                    }
                    return error.MusicTableFull;
                };
                const e: *MusicEntry = state.get(slot).?;
                e.* = .{
                    .in_use = true,
                    .ctx_id = ctx_id,
                    .buffer_id = buf_id,
                    .duration_s = float64(out_frames) /
                        float64(target_rate),
                    .loaded = true,
                };
                return Music{
                    .stream = .{
                        .buffer = @ptrFromInt(@as(usize, slot)),
                        .processor = null,
                        .sampleRate = target_rate,
                        .sampleSize = 32,
                        .channels = 2,
                    },
                    .frameCount = out_frames,
                    .looping = true,
                    .ctxType = 0,
                    .ctxData = null,
                };
            },
            .ogg => {
                // Async path: kick off Web Audio decode.  We cheaply
                // sniff the Ogg container for sample rate, channels,
                // and total samples *before* the decode finishes
                // this lets `getTimeLength` work pre-play and lets
                // callers introspect the stream's native shape.
                const ctx_id: web.audio.ContextId = audio_device.getContextId(device);
                const decode_id: web.audio.DecodeId = web.audio.decodeOggBytes(ctx_id, bytes);
                // On wasm, decode_id == 0 means the bridge rejected
                // the request (invalid ctx, etc.); fail load.  On
                // host the bridge always returns 0 - but we still
                // want the metadata path to work for tests, so
                // accept it there.
                if (decode_id == 0 and comptime builtin.target.cpu.arch.isWasm()) {
                    return .{};
                }
                const slot: u32 = state.allocate() orelse {
                    web.audio.cancelDecode(ctx_id, decode_id);
                    return error.MusicTableFull;
                };
                const meta_opt: ?codecs.audio.ogg.Metadata = codecs.audio.ogg.sniff(bytes);
                const meta_sr: u32 = if (meta_opt) |m| m.sample_rate else audio_device.getSampleRate(device);
                const meta_ch: u32 = if (meta_opt) |m| @as(u32, m.channels) else 2;
                const total_samples: u64 = if (meta_opt) |m| m.total_samples else 0;
                const duration_s: f64 = if (meta_sr > 0)
                    float64(total_samples) / float64(meta_sr)
                else
                    0.0;

                const e: *MusicEntry = state.get(slot).?;
                e.* = .{
                    .in_use = true,
                    .ctx_id = ctx_id,
                    .decode_id = decode_id,
                    .duration_s = duration_s,
                };
                return Music{
                    .stream = .{
                        .buffer = @ptrFromInt(@as(usize, slot)),
                        .processor = null,
                        // Report the SOURCE sample rate / channels (what the
                        // caller would get if they queried the asset).  Web
                        // Audio resamples to context rate on decode; that's
                        // an internal implementation detail.
                        .sampleRate = meta_sr,
                        .sampleSize = 32,
                        .channels = meta_ch,
                    },
                    .frameCount = @intCast(total_samples),
                    .looping = true,
                    .ctxType = 0,
                    .ctxData = null,
                };
            },
        }
    }

    /// Whether the music has loaded and is ready to play.
    /// Promotes async OGG decodes to bound buffers as a side
    /// effect when polling.
    pub fn isReady(
        state: *MusicTable,
        track: Music,
    ) bool {
        const slot: u32 = musicSlot(state, track) orelse return false;
        const e: *MusicEntry = state.get(slot) orelse return false;
        if (e.loaded) {
            return true;
        }
        if (e.decode_id == 0) {
            return false;
        }
        if (!web.audio.isDecodeReady(e.ctx_id, e.decode_id)) {
            return false;
        }
        const buf_id: web.audio.BufferId = web.audio.takeDecodedBuffer(e.ctx_id, e.decode_id);
        e.decode_id = 0;
        if (buf_id == 0) {
            return false;
        }
        e.buffer_id = buf_id;
        e.loaded = true;
        // `duration_s` was set up-front from the Ogg-container sniff
        // (see loadFromMemory → .ogg branch).  Web Audio doesn't
        // expose AudioBuffer.duration through our minimal bridge,
        // and the sniffed value is exact for non-truncated files
        // so we keep it as-is rather than re-querying.
        return true;
    }

    /// Whether the Music slot is allocated and not yet unloaded.
    pub fn isValid(
        state: *const MusicTable,
        track: Music,
    ) bool {
        const slot: u32 = musicSlot(state, track) orelse return false;
        return state.getConst(slot) != null;
    }

    /// Free the Music's resources.
    pub fn unload(
        state: *MusicTable,
        track: Music,
    ) void {
        const slot: u32 = musicSlot(state, track) orelse return;
        const e: *MusicEntry = state.get(slot) orelse return;
        if (e.decode_id != 0) {
            web.audio.cancelDecode(e.ctx_id, e.decode_id);
        }
        if (e.source_id != 0) {
            web.audio.stopBuffer(e.ctx_id, e.source_id);
        }
        if (e.buffer_id != 0) {
            web.audio.unloadAudioBuffer(e.ctx_id, e.buffer_id);
        }
        state.release(slot);
    }

    /// Start (or restart) playback.  Stops any in-flight playback
    /// of THIS track first (raylib parity - Music is single-shot
    /// per track, unlike Sound which can layer).
    /// If `seek` was previously called, playback starts at the
    /// requested offset rather than from 0; the offset is consumed
    /// (subsequent plays without a new seek start at 0 again).
    pub fn play(
        state: *MusicTable,
        track: Music,
    ) void {
        const slot: u32 = musicSlot(state, track) orelse return;
        const e: *MusicEntry = state.get(slot) orelse return;
        if (!e.loaded) {
            if (!isReady(state, track)) {
                return;
            }
        }
        // Stop the previous play if any.
        if (e.source_id != 0) {
            web.audio.stopBuffer(e.ctx_id, e.source_id);
            e.source_id = 0;
        }
        const sid: web.audio.SourceId = if (e.play_offset > 0.0)
            web.audio.playBufferWithOffset(
                e.ctx_id,
                e.buffer_id,
                e.volume,
                e.pitch,
                e.pan,
                e.looping,
                e.play_offset,
            )
        else
            web.audio.playBuffer(
                e.ctx_id,
                e.buffer_id,
                e.volume,
                e.pitch,
                e.pan,
                e.looping,
            );
        e.source_id = sid;
        e.play_started_at = web.audio.getCurrentTime(e.ctx_id);
        // Don't clear play_offset here - getTimePlayed needs it as
        // the base offset for the elapsed calc.  It's reset only
        // by stop() (which restarts from 0) or by a subsequent
        // seek() with a new value.
    }

    /// Stop playback.  Position resets - subsequent `play` starts
    /// from the beginning.
    pub fn stop(
        state: *MusicTable,
        track: Music,
    ) void {
        const slot: u32 = musicSlot(state, track) orelse return;
        const e: *MusicEntry = state.get(slot) orelse return;
        if (e.source_id == 0) {
            return;
        }
        web.audio.stopBuffer(e.ctx_id, e.source_id);
        e.source_id = 0;
        e.play_offset = 0.0;
    }

    /// Seek to `position_seconds` within the track.  Stops any
    /// in-flight playback (Web Audio source nodes can't be re-
    /// positioned after `start`) and starts a fresh source from
    /// the requested offset.  Position is clamped to
    /// `[0, duration]` JS-side.
    /// Whether playback resumes from the new position depends on
    /// whether the track was playing before - `seek` preserves the
    /// playing/paused state.  Seeking a stopped track just records
    /// the new start position; the next `play` will use it.
    pub fn seek(
        state: *MusicTable,
        track: Music,
        position_seconds: f32,
    ) void {
        const slot: u32 = musicSlot(state, track) orelse return;
        const e: *MusicEntry = state.get(slot) orelse return;
        const was_playing: bool = e.source_id != 0;

        // Clamp Zig-side too (defense-in-depth - JS clamps as well).
        const clamped: f32 = @max(0.0, position_seconds);
        const offset: f64 = if (e.duration_s > 0.0)
            @min(@as(f64, clamped), e.duration_s)
        else
            @as(f64, clamped);
        e.play_offset = offset;

        if (!was_playing) {
            return;
        }
        if (!e.loaded) {
            return;
        }
        web.audio.stopBuffer(e.ctx_id, e.source_id);
        e.source_id = web.audio.playBufferWithOffset(
            e.ctx_id,
            e.buffer_id,
            e.volume,
            e.pitch,
            e.pan,
            e.looping,
            offset,
        );
        e.play_started_at = web.audio.getCurrentTime(e.ctx_id);
    }

    /// Pause the current play.  Resume with `resumeMusic`.
    pub fn pause(
        state: *MusicTable,
        track: Music,
    ) void {
        const slot: u32 = musicSlot(state, track) orelse return;
        const e: *MusicEntry = state.get(slot) orelse return;
        if (e.source_id == 0) {
            return;
        }
        web.audio.pauseBuffer(e.ctx_id, e.source_id);
    }

    /// Resume paused playback.  Named `resumeMusic` to dodge
    /// Zig's `resume` keyword.
    pub fn resumeMusic(
        state: *MusicTable,
        track: Music,
    ) void {
        const slot: u32 = musicSlot(state, track) orelse return;
        const e: *MusicEntry = state.get(slot) orelse return;
        if (e.source_id == 0) {
            return;
        }
        web.audio.resumeBuffer(e.ctx_id, e.source_id);
    }

    /// Whether the track is currently playing (not stopped, not
    /// paused, not naturally finished - though looped tracks
    /// never finish naturally).
    pub fn isPlaying(
        state: *const MusicTable,
        track: Music,
    ) bool {
        const slot: u32 = musicSlot(state, track) orelse return false;
        const e: *const MusicEntry = state.getConst(slot) orelse return false;
        if (e.source_id == 0) {
            return false;
        }
        return web.audio.isBufferPlaying(e.ctx_id, e.source_id);
    }

    /// Per-frame update hook.  raylib's Music backend uses this to
    /// keep the streaming buffer ring topped up; on the web our
    /// playback path doesn't need any per-frame work (it's all
    /// scheduled in the AudioContext).  Present for raylib parity
    /// - calling it has no effect here.
    pub fn update(
        state: *MusicTable,
        track: Music,
    ) void {
        _ = state;
        _ = track;
    }

    /// Elapsed playback time in seconds.  For looped tracks,
    /// modulo the track's duration.  Returns 0 for stopped /
    /// not-yet-loaded tracks.
    pub fn getTimePlayed(
        state: *const MusicTable,
        track: Music,
    ) f32 {
        const slot: u32 = musicSlot(state, track) orelse return 0.0;
        const e: *const MusicEntry = state.getConst(slot) orelse return 0.0;
        if (e.source_id == 0) {
            // Not currently playing.  If a seek was issued before
            // the next play, report that position so the UI shows
            // the correct playhead while paused / stopped.
            return @floatCast(e.play_offset);
        }
        const now: f64 = web.audio.getCurrentTime(e.ctx_id);
        const elapsed: f64 = (now - e.play_started_at) * @as(f64, e.pitch);
        // Add the seek offset - playback started `play_offset`
        // seconds into the track, not at 0.
        const total_pos: f64 = e.play_offset + elapsed;
        if (e.duration_s > 0.0 and e.looping) {
            return @floatCast(@mod(total_pos, e.duration_s));
        }
        return @floatCast(total_pos);
    }

    /// Total track length in seconds.  Returns 0 for OGG tracks
    /// still decoding asynchronously (post-decode duration isn't
    /// surfaced - see `isReady` doc).
    pub fn getTimeLength(
        state: *const MusicTable,
        track: Music,
    ) f32 {
        const slot: u32 = musicSlot(state, track) orelse return 0.0;
        const e: *const MusicEntry = state.getConst(slot) orelse return 0.0;
        return @floatCast(e.duration_s);
    }

    /// Set per-track volume.  Stored on the entry; takes effect
    /// on the next `play`.  Clamped to `[0, 10]`.
    pub fn setVolume(
        state: *MusicTable,
        track: Music,
        volume: f32,
    ) void {
        const slot: u32 = musicSlot(state, track) orelse return;
        const e: *MusicEntry = state.get(slot) orelse return;
        e.volume = clamp(volume, 0.0, 10.0);
    }

    /// Set per-track pitch.  Clamped to `[0.0625, 16.0]`.
    pub fn setPitch(
        state: *MusicTable,
        track: Music,
        pitch: f32,
    ) void {
        const slot: u32 = musicSlot(state, track) orelse return;
        const e: *MusicEntry = state.get(slot) orelse return;
        e.pitch = clamp(pitch, 0.0625, 16.0);
    }

    /// Set per-track pan.  Clamped to `[-1, 1]`.
    pub fn setPan(
        state: *MusicTable,
        track: Music,
        pan: f32,
    ) void {
        const slot: u32 = musicSlot(state, track) orelse return;
        const e: *MusicEntry = state.get(slot) orelse return;
        e.pan = clamp(pan, -1.0, 1.0);
    }

    /// Set whether the track loops.  Takes effect on the NEXT
    /// `play` call (Web Audio's source-node loop flag is set at
    /// construction time).
    pub fn setLooping(
        state: *MusicTable,
        track: Music,
        looping: bool,
    ) void {
        const slot: u32 = musicSlot(state, track) orelse return;
        const e: *MusicEntry = state.get(slot) orelse return;
        e.looping = looping;
    }

    fn musicSlot(
        state: *const MusicTable,
        track: Music,
    ) ?u32 {
        _ = state;
        const ptr: *types.rAudioBuffer = track.stream.buffer orelse return null;
        const as_int: usize = @intFromPtr(ptr);
        if (as_int == 0 or as_int > MusicTable.Capacity) {
            return null;
        }
        return @intCast(as_int);
    }

    // ---- Tests
    test "loadFromMemory: WAV path, ready immediately" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        const m: Music = try loadFromMemory(&mt, &dev, &ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&mt, m);
        try expect(isValid(&mt, m));
        try expect(isReady(&mt, m));
        try expectEqual(@as(u32, 48000), m.stream.sampleRate);
    }

    test "loadFromMemory: rejects unknown format" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        try expectError(
            error.UnknownAudioFormat,
            loadFromMemory(&mt, &dev, &ws, ta, ".xyz", "garbage bytes"),
        );
    }

    test "play / stop / isPlaying lifecycle" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        const m: Music = try loadFromMemory(&mt, &dev, &ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&mt, m);
        try expect(!isPlaying(&mt, m));
        play(&mt, m);
        // On host source_id is 0 (no JS bridge), so isPlaying still false.
        // Just verify the call doesn't panic.
        stop(&mt, m);
        try expect(!isPlaying(&mt, m));
    }

    test "pause / resume don't panic" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        const m: Music = try loadFromMemory(&mt, &dev, &ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&mt, m);
        play(&mt, m);
        pause(&mt, m);
        resumeMusic(&mt, m);
        stop(&mt, m);
    }

    test "setVolume / setPitch / setPan / setLooping: clamp + persist" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        const m: Music = try loadFromMemory(&mt, &dev, &ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&mt, m);
        const slot: u32 = musicSlot(&mt, m).?;

        setVolume(&mt, m, 0.5);
        try expectEqual(@as(f32, 0.5), mt.get(slot).?.volume);
        setVolume(&mt, m, -10.0);
        try expectEqual(@as(f32, 0.0), mt.get(slot).?.volume);

        setPitch(&mt, m, 2.0);
        try expectEqual(@as(f32, 2.0), mt.get(slot).?.pitch);
        setPan(&mt, m, -0.5);
        try expectEqual(@as(f32, -0.5), mt.get(slot).?.pan);

        setLooping(&mt, m, false);
        try expect(!mt.get(slot).?.looping);
        setLooping(&mt, m, true);
        try expect(mt.get(slot).?.looping);
    }

    test "getTimeLength: WAV reports proper duration" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        const m: Music = try loadFromMemory(&mt, &dev, &ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&mt, m);
        // 11025 frames × resample 22050→48000 → 24000 frames at 48k = 0.5 s
        try expectApproxEqAbs(@as(f32, 0.5), getTimeLength(&mt, m), 0.05);
    }

    test "update: no-op (raylib parity)" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        const m: Music = try loadFromMemory(&mt, &dev, &ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&mt, m);
        update(&mt, m);
        update(&mt, m);
    }

    test "unload on default Music is silent" {
        var mt: MusicTable = .{};
        unload(&mt, .{});
    }

    test "isValid: rejects empty Music" {
        var mt: MusicTable = .{};
        try expect(!isValid(&mt, .{}));
    }

    test "loadFromMemory: OGG sniffs metadata before decode resolves" {
        // The host stub doesn't actually decode - that's fine, what
        // we're verifying here is that the Ogg-container sniff path
        // populates Music's frameCount / sampleRate / channels /
        // duration_s SYNCHRONOUSLY (before `isReady` would ever
        // return true).  This is the "show duration in the
        // pre-play UI" use case.
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        const m: Music = try loadFromMemory(&mt, &dev, &ws, ta, ".ogg", codecs.audio.ogg.sample_ogg);
        defer unload(&mt, m);

        try expect(isValid(&mt, m));
        // Real container metadata: sample.ogg is 44100 Hz stereo.
        try expectEqual(@as(u32, 44100), m.stream.sampleRate);
        try expectEqual(@as(u32, 2), m.stream.channels);
        // ~96 seconds, ~4.2M frames.  Loose acceptance band.
        try expect(m.frameCount > 3_800_000);
        try expect(m.frameCount < 4_700_000);
        // getTimeLength works pre-play - the whole point of the sniff.
        const len: f32 = getTimeLength(&mt, m);
        try expect(len > 80.0);
        try expect(len < 110.0);
    }

    test "loadFromMemory: OGG truncated bytes still loads (degraded metadata)" {
        // If the container is corrupt enough that `sniff` returns
        // null, music.loadFromMemory should still allocate a slot
        // and kick off the decode (which will eventually fail).
        // The fallback values are device-rate stereo with 0 frames.
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        // Just enough OggS magic to pass Format.detect, but no
        // valid Vorbis ID packet → sniff returns null.
        const garbage_ogg = "OggS\x00\x02" ++ @as([60]u8, @splat(0));
        const m: Music = try loadFromMemory(&mt, &dev, &ws, ta, ".ogg", garbage_ogg);
        defer unload(&mt, m);
        try expect(isValid(&mt, m));
        // Fallback to device sample rate (48000 on host).
        try expectEqual(@as(u32, 48000), m.stream.sampleRate);
        try expectEqual(@as(u32, 0), m.frameCount);
        try expectEqual(@as(f32, 0.0), getTimeLength(&mt, m));
    }

    test "seek: clamps + persists to play_offset" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        const m: Music = try loadFromMemory(&mt, &dev, &ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&mt, m);
        const slot: u32 = musicSlot(&mt, m).?;

        // Seek a stopped track.  No source_id → should just record offset.
        seek(&mt, m, 0.25);
        try expectApproxEqAbs(@as(f64, 0.25), mt.get(slot).?.play_offset, 0.001);
        // getTimePlayed reports the seeked-to position even while stopped.
        try expectApproxEqAbs(@as(f32, 0.25), getTimePlayed(&mt, m), 0.001);

        // Negative gets clamped to 0.
        seek(&mt, m, -10.0);
        try expectEqual(@as(f64, 0.0), mt.get(slot).?.play_offset);

        // Past-duration gets clamped to duration.
        const dur: f32 = getTimeLength(&mt, m);
        seek(&mt, m, dur + 100.0);
        try expectApproxEqAbs(@as(f64, dur), mt.get(slot).?.play_offset, 0.001);
    }

    test "stop: resets play_offset" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var mt: MusicTable = .{};
        const m: Music = try loadFromMemory(&mt, &dev, &ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&mt, m);
        const slot: u32 = musicSlot(&mt, m).?;
        seek(&mt, m, 0.3);
        try expectApproxEqAbs(@as(f64, 0.3), mt.get(slot).?.play_offset, 0.001);
        // Need a source_id for stop to actually do anything.  Force one.
        mt.get(slot).?.source_id = 1;
        stop(&mt, m);
        try expectEqual(@as(f64, 0.0), mt.get(slot).?.play_offset);
    }
};

pub const sounds = struct {
    /// Side-table mapping `Sound.stream.buffer` (cast back to a
    /// non-zero BufferId) to its owning AudioContext + ownership
    /// flag.  We can't grow `Sound`'s extern struct (raylib parity);
    /// this is the workaround that keeps the public type stable.
    const SoundEntry = struct {
        ctx_id: web.audio.ContextId = 0,
        buffer_id: web.audio.BufferId = 0,
        /// Source-node table: each play creates one entry.  Stop /
        /// pause / resume look up by sound + index.  Naturally-
        /// finished sources auto-evict via the JS `onended` handler.
        source_id: web.audio.SourceId = 0,
        /// Last-set playback parameters.  Persist across stop/play
        /// so a `play` after `stop` reuses the volume/pitch/pan the
        /// caller set with `setVolume` / etc.
        volume: f32 = 1.0,
        pitch: f32 = 1.0,
        pan: f32 = 0.0,
        /// Whether THIS Sound owns the underlying buffer (false for
        /// aliases).  Aliases skip the unloadAudioBuffer call.
        owns_buffer: bool = true,
        /// Async-decode tracking for OGG sounds.  When non-zero,
        /// the sound is still loading.
        decode_id: web.audio.DecodeId = 0,
        /// Whether the sound finished its sync-load path (WAV) or
        /// its async-decode path (OGG → BufferId).  Distinct from
        /// `buffer_id != 0` so host tests (where buffer_id is
        /// always 0) work correctly.
        loaded: bool = false,

        in_use: bool = false,
    };

    /// We use the Sound's `stream.buffer` opaque pointer as the
    /// table key (cast from a small integer).  A `[*c]u8` cast of
    /// the buffer_id gives us a stable, compact mapping.
    pub const SoundTable = struct {
        const Capacity = 128;
        entries: [Capacity]SoundEntry = @splat(.{}),

        fn allocate(self: *SoundTable) ?u32 {
            for (&self.entries, 0..) |*e, i| {
                if (!e.in_use) {
                    e.* = .{ .in_use = true };
                    return @intCast(i + 1);
                }
            }
            return null;
        }

        fn get(self: *SoundTable, slot: u32) ?*SoundEntry {
            if (slot == 0 or slot > Capacity) {
                return null;
            }
            const e: *SoundEntry = &self.entries[slot - 1];
            if (!e.in_use) {
                return null;
            }
            return e;
        }

        fn getConst(self: *const SoundTable, slot: u32) ?*const SoundEntry {
            if (slot == 0 or slot > Capacity) {
                return null;
            }
            const e: *const SoundEntry = &self.entries[slot - 1];
            if (!e.in_use) {
                return null;
            }
            return e;
        }

        fn release(self: *SoundTable, slot: u32) void {
            if (slot == 0 or slot > Capacity) {
                return;
            }
            self.entries[slot - 1] = .{};
        }
    };

    /// Build a Sound from an in-memory `Wave`.  Resamples to the
    /// device's sample rate, mixes/expands to stereo, uploads the
    /// buffer through the JS bridge.  The `Wave`'s sample data is
    /// NOT consumed - caller still owns its data and `unload`s it
    /// independently (or doesn't, if it wants to keep editing and
    /// reload).
    /// Reads `device.is_ready` (early-out on closed device),
    /// `device.ctx_id` (target sample rate + Web Audio context for
    /// upload).  Mutates `state.entries` (allocates one slot on
    /// success).  Mutates `gpa.*` (resampled buffer + table
    /// allocations; resampled buffer freed on return).
    /// Returns an empty Sound (`.stream.buffer = null`) if:
    /// - Audio device not initialized (`audio_device.init(device)` first)
    /// - Wave is invalid
    /// - Buffer upload failed (browser denied or out of memory)
    pub fn loadFromWave(
        state: *SoundTable,
        device: *const audio_device.AudioDeviceState,
        gpa: Allocator,
        wave: Wave,
    ) !Sound {
        if (!audio_device.isReady(device)) {
            return .{};
        }
        if (!waves.isValid(wave)) {
            return .{};
        }
        // Convert Wave → CanonicalWave → f32 stereo at the device's rate.
        const cw: codecs.audio.CanonicalWave = codecs.audio.canonicalFromWave(wave);
        const stereo_f32: []f32 = try codecs.audio.wav.toFloat32Stereo(gpa, cw);
        defer gpa.free(stereo_f32);
        const target_rate: u32 = audio_device.getSampleRate(device);
        const resampled: []f32 = if (target_rate == wave.sampleRate)
            try gpa.dupe(f32, stereo_f32)
        else
            try codecs.audio.wav.resampleLinear(gpa, stereo_f32, 2, wave.sampleRate, target_rate);
        defer gpa.free(resampled);

        const ctx_id: web.audio.ContextId = audio_device.getContextId(device);
        const out_frames: u32 = @intCast(resampled.len / 2);
        const buf_id: web.audio.BufferId = web.audio.loadAudioBuffer(
            ctx_id,
            target_rate,
            2,
            out_frames,
            resampled,
        );
        if (buf_id == 0 and comptime builtin.target.cpu.arch.isWasm()) {
            // Real wasm + bridge said no.
            return .{};
        }

        const slot: u32 = state.allocate() orelse {
            if (comptime builtin.target.cpu.arch.isWasm()) {
                web.audio.unloadAudioBuffer(ctx_id, buf_id);
            }
            return error.SoundTableFull;
        };
        state.entries[slot - 1] = .{
            .in_use = true,
            .ctx_id = ctx_id,
            .buffer_id = buf_id,
            .owns_buffer = true,
            .loaded = true,
        };

        return Sound{
            .stream = .{
                .buffer = @ptrFromInt(@as(usize, slot)),
                .processor = null,
                .sampleRate = target_rate,
                .sampleSize = 32,
                .channels = 2,
            },
            .frameCount = out_frames,
        };
    }

    /// Construct a Sound from raw bytes.  WAV path is sync (decodes
    /// immediately, returns ready Sound); OGG path is async (returns
    /// a Sound in "loading" state - `isReady` polls).  For OGG, the
    /// caller must keep `bytes` alive until the decode completes (we
    /// have a copy on the JS side, but we need the format for
    /// dispatch).
    /// Reads `device.is_ready` (early-out), `device.ctx_id` (sample
    /// rate + Web Audio context).  Mutates `state.entries` (one
    /// slot per successful load).  Mutates `waves_state.entries`
    /// for the WAV path (transient - the temporary Wave is unloaded
    /// before return).  Mutates `gpa.*` (decode + resample buffers
    /// + table allocations).
    /// Errors only on truly broken input (unknown format, decode
    /// errors for sync formats).
    pub fn loadFromMemory(
        state: *SoundTable,
        device: *const audio_device.AudioDeviceState,
        waves_state: *waves.AllocTable,
        gpa: Allocator,
        file_type: []const u8,
        bytes: []const u8,
    ) !Sound {
        _ = file_type;
        if (!audio_device.isReady(device)) {
            return .{};
        }
        const fmt: codecs.audio.Format = codecs.audio.Format.detect(bytes) orelse return error.UnknownAudioFormat;
        switch (fmt) {
            .wav => {
                // Sync path: decode → loadFromWave.
                const w: Wave = try waves.loadFromMemory(waves_state, gpa, ".wav", bytes);
                defer waves.unload(waves_state, w);
                return try loadFromWave(state, device, gpa, w);
            },
            .ogg => {
                // Async path: kick off Web Audio decode, return a
                // Sound in loading state.  Sniff Ogg container
                // metadata for the source's sample_rate/channels
                // so callers can introspect before the decode lands.
                const ctx_id: web.audio.ContextId = audio_device.getContextId(device);
                const decode_id: web.audio.DecodeId = web.audio.decodeOggBytes(ctx_id, bytes);
                // Same host-tolerance as music.loadFromMemory: on
                // host the bridge always returns 0 but we still
                // want metadata-only mode to work for tests.
                if (decode_id == 0 and comptime builtin.target.cpu.arch.isWasm()) {
                    return .{};
                }
                const slot: u32 = state.allocate() orelse {
                    web.audio.cancelDecode(ctx_id, decode_id);
                    return error.SoundTableFull;
                };
                const meta_opt: ?codecs.audio.ogg.Metadata = codecs.audio.ogg.sniff(bytes);
                const meta_sr: u32 = if (meta_opt) |m| m.sample_rate else audio_device.getSampleRate(device);
                const meta_ch: u32 = if (meta_opt) |m| @as(u32, m.channels) else 2;
                const total_samples: u64 = if (meta_opt) |m| m.total_samples else 0;
                state.entries[slot - 1] = .{
                    .in_use = true,
                    .ctx_id = ctx_id,
                    .buffer_id = 0,
                    .decode_id = decode_id,
                    .owns_buffer = true,
                };
                return Sound{
                    .stream = .{
                        .buffer = @ptrFromInt(@as(usize, slot)),
                        .processor = null,
                        .sampleRate = meta_sr,
                        .sampleSize = 32,
                        .channels = meta_ch,
                    },
                    .frameCount = @intCast(total_samples),
                };
            },
        }
    }

    /// Build an alias of an existing Sound that shares its
    /// AudioBuffer.  On the web platform this is essentially free
    /// - Web Audio source nodes are already one-shot, so `play` on
    /// either the original or alias creates a fresh source node
    /// from the same buffer.
    /// Aliases do NOT own the buffer; `unload` on an alias just
    /// releases the table slot.  Unloading the original while
    /// aliases are alive leaves the JS-side buffer alive (Web Audio
    /// holds a ref via in-flight source nodes anyway), but future
    /// `play` calls on the alias will fail because the buffer_id
    /// is now stale on the JS side.  Don't do this; unload aliases
    /// before originals.
    pub fn loadAlias(
        state: *SoundTable,
        source: Sound,
    ) Sound {
        const slot: u32 = soundSlot(state, source) orelse return .{};
        const orig_e: *SoundEntry = state.get(slot) orelse return .{};
        const new_slot: u32 = state.allocate() orelse return .{};
        state.entries[new_slot - 1] = .{
            .in_use = true,
            .ctx_id = orig_e.ctx_id,
            .buffer_id = orig_e.buffer_id,
            .owns_buffer = false,
            .loaded = orig_e.loaded,
        };
        return Sound{
            .stream = .{
                .buffer = @ptrFromInt(@as(usize, new_slot)),
                .processor = null,
                .sampleRate = source.stream.sampleRate,
                .sampleSize = source.stream.sampleSize,
                .channels = source.stream.channels,
            },
            .frameCount = source.frameCount,
        };
    }

    /// Whether this Sound has finished loading (relevant for the
    /// async OGG path) and is ready for `play`.  WAV-loaded sounds
    /// are immediately ready.
    pub fn isReady(
        state: *SoundTable,
        sound: Sound,
    ) bool {
        const slot: u32 = soundSlot(state, sound) orelse return false;
        const e: *SoundEntry = state.get(slot) orelse return false;
        if (e.loaded) {
            return true;
        }
        if (e.decode_id == 0) {
            return false;
        }
        // Decode-pending: poll the bridge.
        if (!web.audio.isDecodeReady(e.ctx_id, e.decode_id)) {
            return false;
        }
        // Decode finished - promote to a real BufferId.
        const buf_id: web.audio.BufferId = web.audio.takeDecodedBuffer(e.ctx_id, e.decode_id);
        e.decode_id = 0;
        if (buf_id == 0) {
            // Decode failed; sound stays not-ready forever (caller
            // should unload).
            return false;
        }
        e.buffer_id = buf_id;
        e.loaded = true;
        return true;
    }

    /// Whether this Sound's slot is allocated (has gone through
    /// loadFromWave / loadFromMemory / loadAlias and not yet
    /// unloaded).  Empty defaults / unloaded sounds report false.
    pub fn isValid(
        state: *const SoundTable,
        sound: Sound,
    ) bool {
        const slot: u32 = soundSlot(state, sound) orelse return false;
        return state.getConst(slot) != null;
    }

    /// Free a Sound's resources.  For an owner: unloads the underlying
    /// buffer.  For an alias: releases the slot only.  Idempotent /
    /// silent on unloaded sounds.
    pub fn unload(
        state: *SoundTable,
        sound: Sound,
    ) void {
        const slot: u32 = soundSlot(state, sound) orelse return;
        const e: *SoundEntry = state.get(slot) orelse return;
        // Cancel any in-flight decode.
        if (e.decode_id != 0) {
            web.audio.cancelDecode(e.ctx_id, e.decode_id);
        }
        // Stop any in-flight play.
        if (e.source_id != 0) {
            web.audio.stopBuffer(e.ctx_id, e.source_id);
        }
        if (e.owns_buffer and e.buffer_id != 0) {
            web.audio.unloadAudioBuffer(e.ctx_id, e.buffer_id);
        }
        state.release(slot);
    }

    /// Internal: extract the slot id from a Sound's opaque buffer ptr.
    fn soundSlot(
        state: *const SoundTable,
        sound: Sound,
    ) ?u32 {
        _ = state;
        const ptr: *types.rAudioBuffer = sound.stream.buffer orelse return null;
        const as_int: usize = @intFromPtr(ptr);
        if (as_int == 0 or as_int > SoundTable.Capacity) {
            return null;
        }
        return @intCast(as_int);
    }

    // ---- Playback
    /// Start playing a Sound.  Each `play` call creates a fresh Web
    /// Audio source node (Web Audio nodes are one-shot); re-playing
    /// while a previous play is still active does NOT stop the
    /// previous one - you get two sources playing concurrently.
    /// `stop` stops only the most recently-started source (raylib
    /// parity).  For exhaustive cleanup, `unload`.
    /// Silent if the Sound isn't loaded yet (OGG decode pending) or
    /// has been unloaded.
    pub fn play(
        state: *SoundTable,
        sound: Sound,
    ) void {
        const slot: u32 = soundSlot(state, sound) orelse return;
        const e: *SoundEntry = state.get(slot) orelse return;
        if (!e.loaded) {
            // Try to promote: if the OGG decode just finished,
            // isReady's side effect picks up the BufferId.
            if (!isReady(state, sound)) {
                return;
            }
        }
        const new_source_id: web.audio.SourceId = web.audio.playBuffer(
            e.ctx_id,
            e.buffer_id,
            e.volume,
            e.pitch,
            e.pan,
            false,
        );
        e.source_id = new_source_id;
    }

    /// Stop the most-recently-started in-flight play of this Sound.
    /// Idempotent / silent on already-stopped or never-played sounds.
    pub fn stop(
        state: *SoundTable,
        sound: Sound,
    ) void {
        const slot: u32 = soundSlot(state, sound) orelse return;
        const e: *SoundEntry = state.get(slot) orelse return;
        if (e.source_id == 0) {
            return;
        }
        web.audio.stopBuffer(e.ctx_id, e.source_id);
        e.source_id = 0;
    }

    /// Pause the most-recently-started in-flight play.  Resume with
    /// `resumeSound` (named to dodge Zig's `resume` keyword
    /// collision; raylib calls this `ResumeSound`).
    pub fn pause(
        state: *SoundTable,
        sound: Sound,
    ) void {
        const slot: u32 = soundSlot(state, sound) orelse return;
        const e: *SoundEntry = state.get(slot) orelse return;
        if (e.source_id == 0) {
            return;
        }
        web.audio.pauseBuffer(e.ctx_id, e.source_id);
    }

    /// Resume a paused play.  Named `resumeSound` to dodge Zig's
    /// `resume` keyword.
    pub fn resumeSound(
        state: *SoundTable,
        sound: Sound,
    ) void {
        const slot: u32 = soundSlot(state, sound) orelse return;
        const e: *SoundEntry = state.get(slot) orelse return;
        if (e.source_id == 0) {
            return;
        }
        web.audio.resumeBuffer(e.ctx_id, e.source_id);
    }

    /// Whether the Sound's most-recently-started source is still
    /// playing (not stopped, not paused, not naturally finished).
    pub fn isPlaying(
        state: *const SoundTable,
        sound: Sound,
    ) bool {
        const slot: u32 = soundSlot(state, sound) orelse return false;
        const e: *const SoundEntry = state.getConst(slot) orelse return false;
        if (e.source_id == 0) {
            return false;
        }
        return web.audio.isBufferPlaying(e.ctx_id, e.source_id);
    }

    /// Set the playback volume for this Sound.  Stored on the
    /// Sound's table entry; takes effect on the NEXT `play` call.
    /// (Web Audio's gain node lives inside the source-graph created
    /// by `play` - we'd need a per-Sound persistent gain node to
    /// retro-update in-flight plays, which costs CPU for the common
    /// case.  Future enhancement if needed.)
    /// Clamped to `[0, 10]` matching `web.audio` conventions.
    pub fn setVolume(
        state: *SoundTable,
        sound: Sound,
        volume: f32,
    ) void {
        const slot: u32 = soundSlot(state, sound) orelse return;
        const e: *SoundEntry = state.get(slot) orelse return;
        e.volume = clamp(volume, 0.0, 10.0);
    }

    /// Set the playback pitch (rate multiplier; 1.0 = native).
    /// Same NEXT-`play` semantics as `setVolume`.  Clamped to
    /// `[0.0625, 16.0]` matching `web.audio` conventions.
    pub fn setPitch(
        state: *SoundTable,
        sound: Sound,
        pitch: f32,
    ) void {
        const slot: u32 = soundSlot(state, sound) orelse return;
        const e: *SoundEntry = state.get(slot) orelse return;
        e.pitch = clamp(pitch, 0.0625, 16.0);
    }

    /// Set the stereo pan (`-1.0` = left, `0.0` = center, `+1.0` =
    /// right).  Same NEXT-`play` semantics as `setVolume`.
    pub fn setPan(
        state: *SoundTable,
        sound: Sound,
        pan: f32,
    ) void {
        const slot: u32 = soundSlot(state, sound) orelse return;
        const e: *SoundEntry = state.get(slot) orelse return;
        e.pan = clamp(pan, -1.0, 1.0);
    }

    // ---- Tests
    test "loadFromWave: empty Wave returns empty Sound" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var sd: SoundTable = .{};
        const empty: Wave = .{};
        const s: Sound = try loadFromWave(&sd, &dev, ta, empty);
        try expectEqual(@as(?*types.rAudioBuffer, null), s.stream.buffer);
        try expect(!isValid(&sd, s));
    }

    test "loadFromWave: WAV → Sound on host" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var sd: SoundTable = .{};
        const w: Wave = try waves.loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer waves.unload(&ws, w);
        const s: Sound = try loadFromWave(&sd, &dev, ta, w);
        defer unload(&sd, s);
        try expect(isValid(&sd, s));
        try expect(isReady(&sd, s));
        // Resampled to device rate (48000 Hz), stereo, 32-bit float
        try expectEqual(@as(u32, 48000), s.stream.sampleRate);
        try expectEqual(@as(u32, 2), s.stream.channels);
        try expectEqual(@as(u32, 32), s.stream.sampleSize);
    }

    test "loadFromMemory: WAV path uses sync decode" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var sd: SoundTable = .{};
        const s: Sound = try loadFromMemory(&sd, &dev, &ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer unload(&sd, s);
        try expect(isValid(&sd, s));
        try expect(isReady(&sd, s));
    }

    test "loadFromMemory: rejects unknown format" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var sd: SoundTable = .{};
        try expectError(
            error.UnknownAudioFormat,
            loadFromMemory(&sd, &dev, &ws, ta, ".png", "\x89PNG\r\n\x1a\n"),
        );
    }

    test "loadAlias: shares buffer, no double-unload" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var sd: SoundTable = .{};
        const w: Wave = try waves.loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer waves.unload(&ws, w);
        const s: Sound = try loadFromWave(&sd, &dev, ta, w);
        defer unload(&sd, s);
        const a: Sound = loadAlias(&sd, s);
        defer unload(&sd, a);
        try expect(isValid(&sd, a));
        // Both reference the same buffer, but slots are distinct.
        const slot_s: u32 = soundSlot(&sd, s).?;
        const slot_a: u32 = soundSlot(&sd, a).?;
        try expect(slot_s != slot_a);
        const e_s: *SoundEntry = sd.get(slot_s).?;
        const e_a: *SoundEntry = sd.get(slot_a).?;
        try expectEqual(e_s.buffer_id, e_a.buffer_id);
        try expect(e_s.owns_buffer);
        try expect(!e_a.owns_buffer);
    }

    test "unload: idempotent on default Sound" {
        var sd: SoundTable = .{};
        unload(&sd, .{});
        unload(&sd, .{});
        // Must not panic.
    }

    test "isValid: rejects empty Sound" {
        var sd: SoundTable = .{};
        try expect(!isValid(&sd, .{}));
    }

    test "isReady: false for empty Sound" {
        var sd: SoundTable = .{};
        try expect(!isReady(&sd, .{}));
    }

    // ---- Playback tests
    test "play: no-op on empty Sound" {
        var sd: SoundTable = .{};
        play(&sd, .{});
        // Must not panic.
    }

    test "play / stop / isPlaying lifecycle on host" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var sd: SoundTable = .{};
        const w: Wave = try waves.loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer waves.unload(&ws, w);
        const s: Sound = try loadFromWave(&sd, &dev, ta, w);
        defer unload(&sd, s);

        // Initially not playing.
        try expect(!isPlaying(&sd, s));
        // Host has no JS bridge so play creates source_id = 0.
        // The contract is just "doesn't panic".
        play(&sd, s);
        stop(&sd, s);
        // Still not playing after stop.
        try expect(!isPlaying(&sd, s));
    }

    test "pause / resume don't panic on host" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var sd: SoundTable = .{};
        const w: Wave = try waves.loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer waves.unload(&ws, w);
        const s: Sound = try loadFromWave(&sd, &dev, ta, w);
        defer unload(&sd, s);
        play(&sd, s);
        pause(&sd, s);
        resumeSound(&sd, s);
        stop(&sd, s);
    }

    test "setVolume / setPitch / setPan: clamp + persist on entry" {
        const ta: Allocator = std.testing.allocator;
        var dev: audio_device.AudioDeviceState = .{};
        audio_device.init(&dev);
        defer audio_device.close(&dev);
        var ws: waves.AllocTable = .{};
        var sd: SoundTable = .{};
        const w: Wave = try waves.loadFromMemory(&ws, ta, ".wav", codecs.audio.wav.test_sine_wav);
        defer waves.unload(&ws, w);
        const s: Sound = try loadFromWave(&sd, &dev, ta, w);
        defer unload(&sd, s);
        const slot: u32 = soundSlot(&sd, s).?;

        setVolume(&sd, s, 0.5);
        try expectEqual(@as(f32, 0.5), sd.get(slot).?.volume);
        setVolume(&sd, s, -10.0);
        try expectEqual(@as(f32, 0.0), sd.get(slot).?.volume);
        setVolume(&sd, s, 100.0);
        try expectEqual(@as(f32, 10.0), sd.get(slot).?.volume);

        setPitch(&sd, s, 2.0);
        try expectEqual(@as(f32, 2.0), sd.get(slot).?.pitch);
        setPitch(&sd, s, 0.001);
        try expectEqual(@as(f32, 0.0625), sd.get(slot).?.pitch);

        setPan(&sd, s, -0.5);
        try expectEqual(@as(f32, -0.5), sd.get(slot).?.pan);
        setPan(&sd, s, 5.0);
        try expectEqual(@as(f32, 1.0), sd.get(slot).?.pan);
    }

    test "setVolume on empty Sound is silent" {
        var sd: SoundTable = .{};
        setVolume(&sd, .{}, 0.5);
        setPitch(&sd, .{}, 1.0);
        setPan(&sd, .{}, 0.0);
    }
};

// Force discovery of nested-namespace inline tests.
comptime {
    _ = audio_device;
    _ = waves;
    _ = composer;
    _ = sounds;
    _ = streams;
    _ = music;
}

// ============================================================================
// SECTION - music (raylib's Music - long-form looped audio)
// ============================================================================
// Music in raylib is a long-form, looping playback container
// background music, ambient loops, in-game radio.  The defining
// characteristics vs Sound: streaming-friendly, naturally looped,
// callers expect to update its progress per-frame.
// On the web, both common music formats decode through Web Audio:
//   - WAV: sync decode via `wav.decode`; full PCM in memory; play
//     via a looping AudioBufferSourceNode.  Practical for short
//     loops only (a 3-min stereo WAV at 44.1k = ~30 MB resident).
//   - OGG: async decode via `decodeAudioData`; same looped
//     AudioBufferSourceNode after decode.  Decoded once,
//     played from the resulting AudioBuffer - same RAM cost
//     as WAV after decode but ~10× smaller download.
// For TRUE streaming (decode-as-played, low RAM), the recommended
// pattern is `HTMLAudioElement` + `MediaElementAudioSourceNode`
// - but that requires a DOM bridge to construct `<audio>`
// elements and route them through Web Audio.  This phase ships
// the buffered path; true streaming is a tracked enhancement.
// Music's surface mirrors raylib's verbs exactly:
// `loadFromMemory` / `unload` / `play` / `stop` / `pause` /
// `resumeMusic` / `isPlaying` / `update` / `setVolume` / `setPitch`
// / `setPan` / `setLooping` / `getTimePlayed` / `getTimeLength`.

// ============================================================================
// SECTION - streams (raylib's AudioStream - push-based PCM playback)
// ============================================================================
// `AudioStream` lets callers push f32 PCM frames live: synthesizers,
// procedural music, voice playback.  raylib's audio backend uses
// miniaudio's mixer-managed buffer ring; we approximate with a
// 3-buffer rotation scheduled via `web.audio.playBufferAt` for
// gapless chaining.
// The stream's "head" position lives in AudioContext-time (seconds).
// Each `update(stream, frames)` call:
//   1. Allocates a new Web Audio AudioBuffer and uploads `frames`
//      via `loadAudioBuffer`.
//   2. Schedules it to start at the stream's head time.
//   3. Advances the head by `frames.len / channels / sample_rate`.
// Callers should call `update` at a steady rate (typically once per
// frame from inside the App loop), feeding chunks small enough to
// keep latency low (~20 ms = 960 frames at 48 kHz) but large enough
// to absorb frame-time jitter.  `isProcessed` polls "is the next
// chunk slot free?" - true means the head is within ~one chunk of
// the wall clock and the caller should produce more samples.
// Buffer recycling: completed AudioBuffers are released on the
// next `update` call (we hold a small ring of `BufferId`s and
// unload the oldest when full).

// ============================================================================
// SECTION - sounds (raylib's Sound-typed playback)
// ============================================================================
// `Sound` is a wave-backed playable entity.  Internally it owns a
// reference to a Web Audio AudioBuffer (the BufferId from
// `web.audio.loadAudioBuffer`).  Playing a Sound creates a fresh
// source node - the underlying buffer is shared across every
// in-flight play.
// Sound construction goes through one of:
//   - `loadFromWave(wave)` - synchronous, takes an in-memory Wave.
//     Best path for caller-controlled formats (WAV bytes already
//     decoded, composer-generated).
//   - `loadFromMemory(file_type, bytes)` - sync for WAV, async
//     for OGG.  OGG sounds enter a "loading" state; `isReady`
//     polls.  Calls to `play` before ready are silently dropped.
//   - `loadAlias(source)` - degenerate on the web platform.  Web
//     Audio's source nodes are one-shot; every `play` is implicitly
//     an alias, so `loadAlias` just returns a Sound that shares the
//     same BufferId without owning it.

// ============================================================================
// SECTION - composer (zimr-original synthesis primitives)
// ============================================================================
// Synth + sequencing toolkit.  Inspired by lightmix's Composer
// pattern (build up a list of {wave, start_offset} entries, then
// finalize into a single mixed Wave) but rewritten clean for our
// types and our 16-bit-int target format.
// Three building blocks:
//   - `tone(gpa, opts)` - generate a fixed-duration Wave of a
//     single waveform shape (sine / square / saw / triangle).
//     ADSR envelope optional.
//   - `silence(gpa, opts)` - zero-filled Wave of a given duration.
//     Useful as a sequencer pad.
//   - `Sequence` - a builder for multi-Wave compositions.
//     Place waves at sample-offset positions, finalize to a single
//     mixed Wave.  Mixing is sample-wise summation with hard
//     clipping at i16 range.
// All composer outputs are 16-bit signed PCM at 44100 Hz mono by
// default - these match the `waves` defaults so callers can chain
// directly into `format` if they need a different shape.

// ============================================================================
// SECTION - waves (raylib's Wave-typed loading + manipulation)
// ============================================================================
// `Wave` is the in-memory PCM container - bytes + format header.
// It mirrors raylib's `Wave` extern struct (`types.Wave`) so user
// code that names the type is portable.  Internally we round-trip
// via `codecs.audio.CanonicalWave` for any operation that needs to
// touch the samples uniformly.
// All allocations go through the caller-provided `gpa`.  Waves
// own their `data` pointer until `unload`.

// ============================================================================
// SECTION - AudioState bundle (user-State opt-in for audio resource pools)
// ============================================================================
// Apps that play audio reserve one field of this type in their
// `State` struct.  Apps that don't, don't.  This makes the cost of
// opting into audio visible at the State level.
// `device` is NOT in this bundle - it lives in `Runtime` because
// the JS audio-decode callbacks need to find it at a fixed
// address through the anchor.  Apps reach the device via
// `f.audio_device` (Frame ref into Runtime).
// As Phase C migrates each audio family, its table joins this
// bundle:
//   .music   ✅  Phase C1
//   .sounds  ✅  Phase C2
//   .streams ✅  Phase C3
//   .waves   ✅  Phase C4

pub const AudioState = struct {
    music: music.MusicTable = .{},
    sounds: sounds.SoundTable = .{},
    streams: streams.StreamTable = .{},
    waves: waves.AllocTable = .{},
};
