Skip to content

sokol: collapse audio onto shared labelle-audio mixer (Phase 2 fan-out, f32) - #390

Merged
apotema merged 2 commits into
mainfrom
feat/sokol-audio-shared-mixer
Jun 27, 2026
Merged

apotema merged 2 commits into
mainfrom
feat/sokol-audio-shared-mixer

Conversation

@apotema

@apotema apotema commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

The final backend collapse of the Phase 2 audio pilot — and the f32 path of labelle-audio v0.3.0 in action (sokol_audio's callback is f32).

Changes (backends/sokol) — 1,628 → 626 LOC (~1,000 deleted)

  • New audio/sink.zig — SokolSink satisfies the f32 DeviceSink (sample_format = .f32): wraps sokol_audio's f32 stream callback → MixCallbackF32. The mixer writes normalized [-1,1] f32 directly, no conversion.
  • Mixer(SokolSink) replaces the home-grown f32 mixer + slot state + WAV parser. Deleted: audio_slots.zig (306), audio/state.zig (69), audio/legacy.zig (401), and the mixer half of audio/system.zig.
  • Public API byte-identical (verified by decl/signature diff) — engine/assembler call sites untouched.
  • Depends on labelle-audio v0.3.0.

Why the OGG decoder stayed

The assembler's writeAudioBackendWiring emits direct BackendAudio.decodeAudio/uploadSound calls for every backend and feeds OGG too, but the shared mixer's loadSoundFromMemory is WAV-only. So sokol's dr_wav/stb_vorbis decoder is kept (now audio/decode.zig); uploadSound routes decoded i16 PCM into the mixer via loadSoundFromPcm, and the extern Sound layout is preserved. No codegen changes.

Verified

backends/sokol: zig build + zig build test-host green (15/15). raylib/sdl stay as-is (delegate to full engines).

Phase 2 status

bgfx ✅ wgpu ✅ sokol ✅ (this) — all hand-rolled audio collapsed onto one shared mixer. raylib/sdl independent (no duplication). f32 mixer released v0.3.0.

Summary by CodeRabbit

  • New Features
    • Updated the audio backend to use the shared audio engine for improved playback behavior.
    • Added built-in CPU-side decoding for common formats (WAV and OGG) as part of the audio pipeline.
  • Bug Fixes
    • Improved robustness when loading audio from paths/files, including clearer failure handling for empty, invalid, or unsupported inputs.
    • Strengthened safety around sound/music lifecycle (including handling of inert ID 0 and safer unload behavior).
  • Tests
    • Refreshed audio test coverage with decoder-focused checks and adapter-level smoke/ABI assertions.

Phase 2 audio fan-out, the sokol/f32 case. sokol drives audio through
sokol_audio.h's f32 stream callback, so this uses labelle-audio v0.3.0's
new f32 DeviceSink path: SokolSink declares sample_format = .f32 and the
shared Mixer(SokolSink) renders the mix straight into sokol_audio's
[*]f32 buffer in normalized [-1,1] interleaved samples (no conversion).

- New src/audio/sink.zig: the sokol_audio device + f32 stream callback,
  satisfying the shared f32 DeviceSink contract (ensureStarted/stop/
  framesMixed + sample_format = .f32). 2-channel stereo stream.
- New src/audio/decode.zig: KEPT the Phase 4 OGG/WAV CPU decoder
  (decodeAudio + DecodedAudio/Sound) because the shared mixer decodes
  WAV only and the assembler's writeAudioBackendWiring needs OGG.
- src/audio.zig: thin facade over Mixer(SokolSink); public API names +
  signatures preserved verbatim (legacy load/play + Phase 4 catalog).
  uploadSound routes decoded i16 PCM to Audio.loadSoundFromPcm.
- Deleted the duplicated mixer/slots/WAV-parser: audio_slots.zig +
  audio/{state,system,legacy,phase4}.zig (~1000 LOC net removed). Their
  slot/mixer/#298/#110/#111 logic now lives in labelle-audio.
- build.zig: wire labelle-audio v0.3.0 into the audio module; drop the
  audio_slots host test (moved to labelle-audio).
- build.zig.zon: add labelle-audio v0.3.0 dep.
@coderabbitai

coderabbitai Bot commented Jun 27, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ab8bddd-eb00-48c6-9a70-23b3edad05ad

📥 Commits

Reviewing files that changed from the base of the PR and between b4bbaea and 7e1877b.

📒 Files selected for processing (3)
  • backends/sokol/src/audio/decode.zig
  • backends/sokol/src/audio/sink.zig
  • backends/sokol/src/audio/tests.zig
🚧 Files skipped from review as they are similar to previous changes (3)
  • backends/sokol/src/audio/decode.zig
  • backends/sokol/src/audio/tests.zig
  • backends/sokol/src/audio/sink.zig

📝 Walkthrough

Walkthrough

The sokol audio backend now forwards audio control and decoding through labelle_audio.Mixer(SokolSink). It adds a Sokol device sink, a CPU WAV/OGG decode module, adapter wrappers for legacy sound and music APIs, updated tests, and new build wiring. Old in-tree audio modules are removed.

Changes

Sokol Audio Backend Migration to labelle-audio

Layer / File(s) Summary
Build dependency and test wiring
backends/sokol/build.zig.zon, backends/sokol/build.zig
Adds the labelle_audio dependency, imports labelle-audio into the backend audio module, and removes the obsolete audio_slots host test wiring.
Sokol f32 device sink
backends/sokol/src/audio/sink.zig
Defines the Sokol audio sink, stream callback, lazy startup/shutdown, and mixed-frame tracking for the shared mixer.
CPU decode module
backends/sokol/src/audio/decode.zig
Adds DecodedAudio, Sound, decodeAudio, WAV/OGG decoding, and decoder tests.
audio.zig adapter facade
backends/sokol/src/audio.zig
Replaces the old façade with wrappers over labelle_audio.Mixer(SokolSink), file-loading helpers, legacy sound/music forwarding, and Phase 4 upload/unload wiring.
Adapter smoke tests
backends/sokol/src/audio/tests.zig
Rewrites tests to pull in decoder tests and check upload validation, ABI shape, and legacy zero-id behavior.
Deleted modules
backends/sokol/src/audio/legacy.zig, backends/sokol/src/audio/phase4.zig, backends/sokol/src/audio/state.zig, backends/sokol/src/audio/system.zig, backends/sokol/src/audio_slots.zig
Removes the in-tree audio engine modules now replaced by the shared mixer and new adapter files.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 Hop, hop, the old tunes fade away,
A shared mixer now carries the day.
Sokol sings with decode and sink,
Fast little paws keep time and sync.
Old modules vanish, neat and bright —
One rabbit hums the rhythm right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: sokol audio now uses the shared labelle-audio mixer on the f32 path.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sokol-audio-shared-mixer

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements Phase 2 of the pluggable-backends RFC by offloading WAV decoding, PCM mixing, and slot management from the Sokol audio backend to the shared labelle-audio package. The local slot management and legacy audio code have been removed, and audio.zig has been refactored into a thin adapter. A new sink.zig implements the f32 DeviceSink contract, while decode.zig retains the CPU-based OGG/WAV decoder. The review feedback suggests improving safety in decode.zig by replacing direct @intCast calls with std.math.cast to handle malformed files gracefully, and removing a redundant saudio.isvalid() check in sink.zig to prevent potential resource leaks during shutdown.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread backends/sokol/src/audio/decode.zig Outdated
Comment on lines +85 to +87
const total_frames: usize = @intCast(wav.totalPCMFrameCount);
const channels: u8 = @intCast(wav.channels);
if (total_frames == 0 or channels == 0) return error.AudioDecodeFailed;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using @intCast directly on wav.totalPCMFrameCount and wav.channels can cause a panic in safety-checked builds if the values are out of bounds for usize or u8 (e.g., on 32-bit platforms or with malformed WAV files). Using std.math.cast is much safer as it returns null on overflow, allowing us to handle the error gracefully.

    const total_frames = std.math.cast(usize, wav.totalPCMFrameCount) orelse return error.AudioTooLarge;
    const channels = std.math.cast(u8, wav.channels) orelse return error.AudioDecodeFailed;
    if (total_frames == 0 or channels == 0) return error.AudioDecodeFailed;

Comment thread backends/sokol/src/audio/decode.zig Outdated
Comment on lines +121 to +127
const channels: u8 = @intCast(info.channels);
const sample_rate: u32 = @intCast(info.sample_rate);
if (channels == 0) return error.AudioDecodeFailed;

const total_samples_c = stbv.stb_vorbis_stream_length_in_samples(vorbis);
const total_frames: usize = @intCast(total_samples_c);
if (total_frames == 0) return error.AudioDecodeFailed;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to decodeWav, using @intCast directly on info.channels, info.sample_rate, and total_samples_c can cause a panic if the values are out of bounds (e.g., on 32-bit platforms or with malformed OGG files). We should use std.math.cast to safely cast these values and handle any overflow gracefully.

    const channels = std.math.cast(u8, info.channels) orelse return error.AudioDecodeFailed;
    const sample_rate = std.math.cast(u32, info.sample_rate) orelse return error.AudioDecodeFailed;
    if (channels == 0) return error.AudioDecodeFailed;

    const total_samples_c = stbv.stb_vorbis_stream_length_in_samples(vorbis);
    const total_frames = std.math.cast(usize, total_samples_c) orelse return error.AudioTooLarge;
    if (total_frames == 0) return error.AudioDecodeFailed;

Comment on lines +128 to +130
if (device_started.load(.acquire)) {
if (saudio.isvalid()) saudio.shutdown();
device_started.store(false, .release);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The saudio.isvalid() check before calling saudio.shutdown() is redundant because device_started is only set to true if saudio.setup was successfully called. Furthermore, if the device somehow becomes invalid after setup, skipping saudio.shutdown() would leak the setup state and leave sokol_audio in an inconsistent state (as saudio.shutdown is required to reset the internal setup_called flag). We should call saudio.shutdown() unconditionally if device_started is true.

    if (device_started.load(.acquire)) {
        saudio.shutdown();
        device_started.store(false, .release);

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backends/sokol/src/audio/decode.zig`:
- Around line 85-87: The narrowing casts in the audio decode path can trap
before the function returns an audio error, so guard the input sizes first and
only cast after validating bounds. In the WAV branch around
wav.totalPCMFrameCount and wav.channels, check for zero/overflow using the
original values before calling `@intCast`, and do the same in the OGG path for
data.len, total_samples_c, and total_samples so oversized inputs are rejected
gracefully instead of trapping. Keep the fix localized to decode.zig and
preserve the existing error.AudioDecodeFailed flow.

In `@backends/sokol/src/audio/sink.zig`:
- Around line 102-117: The audio sink setup path in `sink.zig` leaves `saudio`
in setup state when `saudio.isvalid()` fails, which can cause a later
`ensureStarted()` to attempt setup again on the same context. In the failed
branch of the `saudio.setup()` / `saudio.isvalid()` flow, add a
`saudio.shutdown()` before returning or exiting the branch so the Sokol audio
state is reset cleanly. Use the existing `saudio` setup/validation block and
keep the successful `device_started.store(true, .release)` path unchanged.

In `@backends/sokol/src/audio/tests.zig`:
- Around line 39-46: The test around audio.uploadSound currently relies on
explicit cleanup after the assertion, so a failure in testing.expect can skip
unloadSound and leak the uploaded slot into later tests. Add a defer immediately
after the successful uploadSound call in tests.zig so teardown always runs, and
keep the existing explicit unloadSound call only if you still want to verify
double-unload behavior in this test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3741094d-3f5c-4173-8c55-f1e5dccb7009

📥 Commits

Reviewing files that changed from the base of the PR and between 44248aa and b4bbaea.

📒 Files selected for processing (11)
  • backends/sokol/build.zig
  • backends/sokol/build.zig.zon
  • backends/sokol/src/audio.zig
  • backends/sokol/src/audio/decode.zig
  • backends/sokol/src/audio/legacy.zig
  • backends/sokol/src/audio/phase4.zig
  • backends/sokol/src/audio/sink.zig
  • backends/sokol/src/audio/state.zig
  • backends/sokol/src/audio/system.zig
  • backends/sokol/src/audio/tests.zig
  • backends/sokol/src/audio_slots.zig
💤 Files with no reviewable changes (5)
  • backends/sokol/src/audio/system.zig
  • backends/sokol/src/audio_slots.zig
  • backends/sokol/src/audio/phase4.zig
  • backends/sokol/src/audio/state.zig
  • backends/sokol/src/audio/legacy.zig

Comment thread backends/sokol/src/audio/decode.zig Outdated
Comment thread backends/sokol/src/audio/sink.zig
Comment thread backends/sokol/src/audio/tests.zig Outdated
…wn (review)

- decode.zig: std.math.cast (null on overflow) instead of @intcast for WAV/OGG
  frame-count/channels/sample-rate — a malformed file or 32-bit usize can't
  panic the cast now (Gemini + CodeRabbit).
- sink.zig stop(): drop the redundant inner isvalid() guard — device_started is
  only set when setup succeeded, so saudio.shutdown (asserts setup_called, not
  isvalid) is safe + required unconditionally; fixed the misleading comment
  (CodeRabbit + Gemini).
- tests.zig: defer the teardown so a failed assertion can't leak the slot into
  the next test (CodeRabbit).
@apotema
apotema merged commit 268a9df into main Jun 27, 2026
5 of 6 checks passed
@apotema
apotema deleted the feat/sokol-audio-shared-mixer branch June 27, 2026 22:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant