Skip to content

No Sound fix - #69

Merged
murkantor merged 2 commits into
murkantor:mainfrom
JayBeest:sound-fix
Oct 23, 2026
Merged

murkantor merged 2 commits into
murkantor:mainfrom
JayBeest:sound-fix

Conversation

@JayBeest

@JayBeest JayBeest commented Oct 21, 2026 •

Copy link
Copy Markdown

Fixes the "completely silent on some systems" reports — #19 (Batocera /
32-bit PipeWire), #67 (Linux: No Audio on Arch with kernel "7.2.11-zen1-1-zen" using PipeWire audio) and (might not) #56 (Windows 11, all-zero sdlaudio.raw).

Symptom

The game runs, renders, responds to input, and produces no sound at all.
Music and SFX are both at 100%. The log looks healthy, because the only audio
line it prints is:

ge: audio: global volumes - music 100%, sound 100%

On Linux the failure also leaves a trail, because an SFX event queue that is
never drained eventually reports itself:

ge: snd: evtq FULL - 256 queued (254 due now) | pan 0 vol 0 play 18 decay 0
stop 0 end 0 other 238 | states 21 live, 0 voiced, 0 waiting for a voice
(0 of 16 voices) | heartbeat alive | coalesced 0 evicted 0 dropped 3328
ge: snd: event queue FULL - dropped event type 1024 (drop #3329)

That line is the whole diagnosis, and the numbers in it are exact:

  • 256 queued is this build's queue capacity, not a round number:
    MUSIC_SFX_SEQ_CONFIG_MAX_EVENTS (0x40) × MUSIC_POOL_SCALE (2) ×
    MUSIC_SFX_EVENT_HEADROOM (2) = 256. The census only prints on a drop,
    so 256 means 100% full.
  • 254 due now — the events are ready to run and nothing is running them.
  • 0 voiced, 0 of 16 voices — no synth voice was ever allocated.
  • heartbeat alive — the game thread is still posting happily.

Sound is not quiet. The synthesizer was never executed.

Root cause

portAudioPump() (port/src/audio_pc.c) is the only thing that runs the AL
synthesizer: alAudioFrame() is the sole caller of every AL client handler
(src/libultra/audio/synthesizer.c:189), so the SFX player's event queue, the
sound-state list and all three music sequencers advance only when the pump
renders. The synthesizer's clock is the game's audio clock.

The pump had three one-way exits, and two of them let the audio device decide
whether that clock runs at all:

if (s_AudioDead) return;                                        // permanent
if (SDL_GetQueuedAudioSize(dev) >= PORT_AUDIO_QUEUE_LOW) break; // per pump

So a device that cannot be opened — or that opens and then stops consuming —
freezes the entire audio subsystem, and the event queue saturates exactly as
logged above. s_AudioDead was set on any SDL_OpenAudioDevice failure and
never cleared, and the failure path printed nothing: no SDL_GetError(), no
driver name, no obtained format. This was the only SDL subsystem in the port
that failed without logging (input_pc.c:2119, video.c:5156,
raphnet.c:289 all log).

That is also why the reports are machine-specific while the code is not. The
port asks for an exact 22050 Hz / s16 / stereo device with
allowed_changes = 0. Almost no system has that natively; it works only
because the backend converts behind SDL's back — WASAPI AUTOCONVERTPCM,
CoreAudio, ALSA plug, PulseAudio/PipeWire resampling. Where that layer is not
reachable from a 32-bit client (the Batocera report), or the endpoint refuses
the spec, the open fails and the game goes permanently and silently mute.
Machines where audio works never touch either exit, which is why this survived
so much testing.

Changes

Both are in port/src/audio_pc.c.

1. Device setup is logged and retried, not fatal

portAudioInit() now returns success/failure and reports why on every exit
that leaves the game mute — the operation, SDL_GetError(), the current audio
driver, the attempt number, and the retry cadence. A successful open logs the
driver and the obtained spec:

ge: audio: device open - driver pipewire, 22050 Hz, 2 ch, 512 samples, 4096 byte buffer
ge: audio: SDL_OpenAudioDevice(22050 Hz, s16, stereo) failed (SDL: ...; driver none) -
    no output, attempt 1, retrying every 2000 ms

s_AudioDead is gone. The pump retries every PORT_AUDIO_RETRY_MS (2 s), so a
sink that appears or changes after boot is picked up: a USB headset plugged in,
a PipeWire restart, a 32-bit client stack that was not up yet. Failures are
rate-limited (first, then every 30th) so a permanently mute machine cannot bury
the rest of the log.

2. The render pace is wall time, and the device can no longer stall it

The pump now keeps an audio clock in samples owed (stored ×1000 to avoid
rounding a 147 Hz pump's fractional share of a 736-sample frame down to zero),
advanced by real elapsed wall time, clamped to 100 ms per pump so a debugger
pause or a long load cannot replay minutes of audio at once.

A frame is rendered when either:

  • the clock owes a whole frame — this is what keeps game audio time honest with
    no device attached, and
  • the playback queue is below PORT_AUDIO_QUEUE_LOW — the existing catch-up
    behaviour, unchanged, so a live DAC still sets the fine pace.

Neither branch can be blocked by the device. SDL_QueueAudio is still
skipped when the queue is over PORT_AUDIO_QUEUE_MAX; the frame is discarded,
not the render: the clock has already advanced, so dropping it costs sound, not
sequencer time.

Also fixed while here: portAudioPumpForce() now renders exactly one frame, as
its comment always claimed. The old code bypassed the queue gate per iteration,
so a forced call could render up to 8 frames, and music.c's stop-wait spins it
up to 512 times.

3. A sink that opens and never consumes is detected

The second mute shape: the device opens, the queue never drains, the top-up
stops, and nothing plays again. An unmoving full backlog for
PORT_AUDIO_STALL_PUMPS (~1 s) now closes the device and lets the retry path
reopen it, logged and rate-limited. This is the one heuristic piece here — it
is deliberately conservative (queue at the cap and byte-identical across
pumps), but it is the part most worth watching in field reports.

What this does not change

  • The healthy path. Queue-driven catch-up is still there; the wall clock only
    adds a floor, so a machine where audio already works renders at the same
    rate as before.
  • The 22050 Hz / s16 / stereo requirement with allowed_changes = 0. Accepting
    a device rate would need an SDL_AudioStream in front of the synth, because
    audi.c:344 bakes the rate into g_FrameSize. That is the natural follow-up
    (see below), not part of this patch.
  • portAcmdRun's whole-frame validation bail-out (port/src/audio_pc.c,
    portAcmdValidate), which can silently discard every frame of a run. It only
    reports through GE_DUMP_BOND, so it stays invisible in normal logs. Flagged
    for a separate change.

Verification

What was checked:

  • The file compiles clean with the exact flags and defines of the macOS build
    (-fsyntax-only, -std=gnu11 -arch arm64 -DGE_NATIVE_64=1 -DGE_VIDEO=1 ...,
    taken from that build's build.ninja). The unmodified file compiles clean in
    the same harness, so the comparison is meaningful.
  • -Wall -Wextra produces no new diagnostics in the changed region; the only
    warnings are the pre-existing u32→pointer casts in the aspMain interpreter.

What was not checked, and should be before merge:

  • No full build and no runtime test: this checkout is 32-bit-only upstream and
    the available machine is arm64 macOS, so the game was not run with this
    patch. Timing behaviour in particular (frame cadence at 60/120/147 Hz pumps)
    wants a real play session.
  • The stall detector has not been exercised against a real stalled sink.

What to look for in a run log after this lands:

  • ge: audio: device open - driver ... — confirms the sink and its real rate.
  • ge: audio: ... failed ... retrying — the mute report now names its cause.
  • The absence of ge: snd: evtq FULL on a machine with no audio — the game's
    audio clock should stay healthy even when nothing is plugged in.

Follow-ups

  1. SDL_AudioStream fallback so a device that refuses 22050 Hz is accepted at
    its own rate instead of failing (open with
    SDL_AUDIO_ALLOW_FREQUENCY_CHANGE, stream 22050 → device rate).
  2. Make portAcmdValidate's bail-out zero the frame and log unconditionally,
    so a garbage Acmd can never silently produce an all-zero run.
  3. Trim the queue back to target instead of pinning at the cap, so a long
    session cannot accumulate fixed audio latency.

For reporters, after the build lands

# is a stream reaching the server at all?
pactl list short sink-inputs
SDL_AUDIODRIVER=disk ./ProjectMidas     # app-side isolation; writes sdlaudio.raw
python3 -c "d=open('sdlaudio.raw','rb').read(); print(len(d), sum(1 for b in d if b))"

Windows: SDL_AUDIODRIVER=disk ProjectMidas.exe, then check sdlaudio.raw
size and look for ge: audio: lines in Data/Logs/ge_direct.log. Before this
patch a mute system produced no ge: audio: line beyond the volume print; from
now on it produces a reason.

Type of Change

Please delete options that are not relevant.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Other

How Has This Been Tested?

Checklist

  • My code has been thoroughly tested and is free of bugs
  • I have performed a self-review of my code, not fully, I have to say, not a sound-expert here, then again I DO agree with the logic, and HAVE created functioning audio/video pumps before
  • I have commented my code, particularly in hard-to-understand areas
  • The commits I've made a clean, small and easy to understand
  • My changes generate no compiler warnings
  • My Code is AI-Generated or AI-assisted (Codewhale v0.10.1 <> DeepSeek V4.1-Flash-1017, heavily audited and directed by me.)
  • My PR contains no AI-generated images, textures, models, sound or music (these are never accepted)
  • I am an AI (please specify which AI model you are)
  • I have reviewed my PR to make sure the above is truthful and accurate. I understand my PR can be rejected if it doesn't meet this project's standards.

jcorneli added 2 commits October 21, 2026 23:46
Two silent-system reports (murkantor#19 Batocera, murkantor#56 Windows 11) share a cause that
looks nothing like "no sound": the AL synthesizer was never executed.

portAudioPump is the only caller of alAudioFrame, and alAudioFrame is the only
caller of every AL client handler (synthesizer.c:189), so the SFX event queue,
the sound-state list and the three music sequencers advance only while the pump
renders. The pump had two device-gated exits - a permanent s_AudioDead flag set
by any SDL_OpenAudioDevice failure, and a per-pump break while the playback
queue sat above PORT_AUDIO_QUEUE_LOW - so a device that could not be opened, or
that opened and then stopped consuming, froze game audio state outright. The
Linux report shows the result exactly: the SFX queue pinned at its capacity of
256 (0x40 x GE_SNDSCALE 2 x MUSIC_SFX_EVENT_HEADROOM 2), 3328 events dropped,
no voice ever allocated, and the API heartbeat still alive.

No system has 22050 Hz s16 stereo natively; it works only because every healthy
backend converts behind SDL. Where a 32-bit client cannot reach that layer, the
open failed and the game went mute with no message - this was the only SDL
subsystem in the port failing without logging, and the Windows build opens no
console for anyone to notice.

Changes, all in port/src/audio_pc.c:

- portAudioInit returns success/failure and logs the driver and the obtained
  format, or the failing operation with SDL_GetError(). A failed open is
  retried every 2 s instead of being permanent, so a sink that appears or moves
  after boot (headset plugged in, PipeWire restart, late 32-bit client stack)
  is picked up. Failures are rate-limited so a permanently mute machine cannot
  bury the rest of the log.

- The render pace is wall time, with the playback queue as a catch-up trigger
  rather than a gate: a frame renders when either the audio clock owes one or
  the queue is below PORT_AUDIO_QUEUE_LOW, so no device state can stall the
  synthesizer. The clock is kept in samples x1000 so a 147 Hz pump's fractional
  share of a frame cannot round to zero, and catch-up is clamped to 100 ms per
  pump so a debugger pause or a long load cannot replay minutes of audio back
  to back. An over-cap queue now discards that frame, not the render.

- An unmoving full queue for ~1 s is treated as a dead sink: close it and let
  the retry path reopen whatever is there now. This is the one heuristic piece
  and the part most worth watching in field reports.

- portAudioPumpForce renders exactly one frame, as its comment always claimed.
  It previously bypassed the queue gate per iteration, so a forced call could
  render eight, and music.c's stop-wait spins it up to 512 times.

The healthy path is unchanged: queue-driven catch-up still runs and the wall
clock only adds a floor, so systems where audio already worked render at the
same pace as before.

printf is declared locally rather than pulling <stdio.h>: PR/os.h declares
sprintf itself, which collides with the hosted header's fortified macros on
macOS, and this TU includes ultra64.h before any libc header. Same workaround
as src/snd.c and port/src/printf_port.c.

Verified: compiles clean with the macOS build's exact flags (-fsyntax-only,
-std=gnu11, -arch arm64, -DGE_NATIVE_64=1 -DGE_VIDEO=1 ...) and adds no
-Wall -Wextra diagnostics in the changed region. Not verified: no full build
and no runtime test here - upstream is 32-bit and this machine is arm64 macOS -
so frame cadence at 60/120/147 Hz pumps and the stall detector still want a
real play session.

Follow-ups, deliberately not in this commit:

- An SDL_AudioStream fallback so a device that refuses 22050 Hz is accepted at
  its own rate (the synth rate is baked into g_FrameSize, audi.c:344, so the
  request cannot simply be relaxed).
- portAcmdValidate's whole-frame bail-out discards every frame of a run while
  reporting only through GE_DUMP_BOND. That is invisible in normal logs and is
  the leading suspect for the Windows murkantor#56 report, which shows no event-queue
  starvation at all.
- Trim the queue back to target instead of pinning it at the cap, so a long
  session cannot accumulate fixed audio latency.
@JayBeest

JayBeest commented Oct 21, 2026 •

Copy link
Copy Markdown
Author

Hey all, throwing this up as a draft because I know this touches the core audio pacing. I've verified the syntax compiles, but as noted, I couldn't reproduce the original mute bug on my Mac, so the stall detector and wall-clock logic haven't been stress-tested on a real Windows/Linux machine. I'm hoping the logging improvements alone are valuable enough to start a discussion on how to properly handle the audio device failures. Happy to iterate on the pacing logic if anyone has thoughts.

Edit: Oh god, I did it again, forgot to change branches...lemme try and clean up this mess...

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.

2 participants