Repository navigation
Conversation
Convert serialized ROM records to native layouts at load boundaries and remove 32-bit address and runtime-layout assumptions. Add Cocoa/SDL build and fullscreen support, ROM-free regression fixtures, macOS CI, build documentation, and converter attribution.
Fixes the crackle/pop and slow-music report in murkantor#82, and the reload-sound latency that preceded it. The pump in 1.0.2b took its render count from the simulation's field counter (src/game/frametiming.c currentFrameCounter) and corrected an over-full queue by skipping whole rendered field frames. Both are wrong in ways that compound: - alAudioFrame is the ONLY thing that advances the game's audio clock. The sound player's event queue and the three music sequencers all move in rendered samples (src/libultra/audio/synthesizer.c:143-227). So the render count is simultaneously "how much audio" and "how fast audio time runs", and tying it to the game loop means every stalled frame (stage load, the intro swirl, a window drag, alt-tab) delivers no audio: the device drains, plays silence, and resumes with a discontinuity. That is a click. Often enough, it is continuous crackle and music that runs behind real time. It also made audio behaviour depend on maxfps, since at uncapped rates the field delta is usually 0 and only the queue-low branch rendered at all. - Dropping a rendered frame throws away ~16.7 ms of synthesised audio mid-stream. That is a step discontinuity by construction - the N64 never did it. src/audi.c:529 sized the frame from the AI's remaining buffer instead ((g_FrameSize - (osAiGetLength() >> 2)) + 16 + EXTRA_SAMPLES): a sample-level servo, inaudible correction. The port lost it because portAudioPump calls alAudioFrame directly and the shim stubs osAiGetLength() to 0. Both are fixed by splitting the one decision into the two it always was: - A LIVE DEVICE SETS THE RATE. Refill toward the target depth. The render rate then equals the DAC's consumption rate - the queue is stable, device clock drift is absorbed, and audio time is the hardware's own time. This is what amHandleFrameMessage did. The correction is a few samples of frame length, so nothing is ever dropped or duplicated. - THE WALL CLOCK SETS THE RATE ONLY WHEN THERE IS NO USABLE DEVICE, and those samples are discarded. Game audio time still advances, so a missing or wedged sink cannot freeze the sequencers or saturate the SFX queue. Also in this change: - Target raised to 40 ms (from 40.6 ms nominal, but now actually held) and it ratchets up on an underrun, easing back only in a clean 10 s window. The effective target is floored at one pump interval plus a device period: a target shallower than the interval between pumps starves by construction. - The pump-interval floor is why 40 ms is enough. At 60 fps the interval is 16.7 ms, so the floor never binds; below ~20 fps it takes over. - SINGLE-DIGIT FRAME RATES ARE SUPPORTED. Classic and demo modes can drop there, and at N fps the pump runs N times a second, so each pump must bridge 1000/N ms of audio. A fixed 16 KB cap holds only 185 ms, which is less than one interval at 5 fps, so the cap now scales with the interval (and the per-pump render budget with it). Stated plainly: at those rates the backlog IS the audio latency - 500 ms at 2 fps. That is inherent to pumping from the render loop; continuous audio at high latency beats crackle at low latency. - portAudioPumpForce no longer floods the queue during music.c's track-stop spins, which used to push seconds of audio into a 186 ms buffer and leave the trim to clean up. A forced frame advances the sequencer and is queued only when the queue is genuinely shallow. - The queue cap is checked BEFORE alAudioFrame, not after. Rendering a frame that cannot be queued is not free: alAudioFrame advances the game's audio clock, so the drop path both burned the time and left the clock ahead of what was delivered. Nothing is now synthesised that cannot be delivered. - The pacing rules moved into port/src/audio_pace.c + port/include/audio_pace.h as pure functions - no SDL, no engine headers, no globals, no clock reads - driven by port/tests/audio_pace.c under GE_PORT_TESTS=ON (ctest -R audio_pace). - GE_AUDIODBG=1 prints a one-second trace (pumps, samples rendered vs needed, queue open->close vs target, clock credit, stalled, forced) so the next audio report can be read instead of guessed at. Two bugs this change found by running the game rather than reasoning about it, both now pinned by tests named after them: - The first cut paced on the wall clock AND steered the queue from the same budget (credit time, add a bounded bias toward the target, charge it back). It compiled and passed 15 tests including a closed-loop simulated device, and it starves on hardware: if total rendered must equal wall time while the device also consumes wall time, the queue depth is a constant of integration. A bounded bias changes when samples arrive, never the running total. Measured: queue cycling 1.2-3.6 KB against a 10 KB ratcheted target, 24 dry pumps in the first 10 s. - Rounding the refill DOWN to the 16-sample alignment loses a few samples on every pump - ~11% short at 300 fps, which drains the queue whatever the target is. Round up; the "at or above the mark" early return is the hysteresis. And one caught by review after the fact, now fixed: the over-cap check sat after alAudioFrame, so a frame that did not fit was synthesised and then discarded, which is exactly what the comment above it claimed to prevent. Known limits, deliberately left for their own changes: - The device is still opened at an exact 22050 Hz with allowed_changes = 0. On Windows shared mode (48000) that puts an OS resampler and a fallback driver between us and the DAC, with mixer quanta near our 11.6 ms device period - a plausible environmental cause for murkantor#82 that we cannot reproduce on macOS. Briefing for the SDL_AudioStream fix: docs/port/rate_prompt.md. - A forced frame advances the sequencer's musical time without delivering it when the queue is not shallow, so a track-stop burst can push the playing tracks slightly ahead. Pre-existing (1.0.2b rendered and dropped the same frames) and bounded by the stop; the real fix is wall-clock pacing those spins in music.c. Verified on macOS/CoreAudio at 22050 Hz, 256-sample period, title screen (stage 90), 24 s run with GE_AUDIODBG=1: before, queue 1216->3584 B against a 10240 target with 24 dry pumps in the first window; after, queue steady at 3556 B against a 3520 target and no `ge: audio: last N s:` health line at all - zero dry pumps, zero over-cap drops, zero skipped frames. ctest: 8/8, including the 16 audio_pace cases (which cover 1-300 fps). Not verified on Windows, which is the reporter's platform; the low-frame-rate path is covered by simulation only, not by a real single-digit-fps run. Full background, including the four earlier pump designs and why each was replaced: docs/port/audio-pump-history.md.
added 2 commits
October 24, 2026 23:01
`cl_audio_pump 1` draws the pump's state as six rows in the top-left corner:
FPS and TPS, pump calls against the ones that did work, queue against its
target and the cap, ms of audio rendered against the ms of wall time it was
worth, the four failure counters, and the stall verdict with the wall-clock
credit and device rate. `cl_audio_log 1` writes the same numbers, in the same
units, to Data/Logs/audio_pump.log once a second, flushed per line so a crash
keeps the tail. Neither is a midas.ini key: they are diagnostics, and a restart
should not leave one on because it was typed once.
The block REPLACES the FPS counter rather than sitting under it. show_fps draws
one number there and the block carries FPS and TPS itself, so with both on the
same two figures would print twice; the digits are suppressed while the block
is up, and the block takes over the corner the strip used, not a row below it.
The snapshot lives in port/include/port_audio_stats.h and is filled by the pump
in audio_pc.c, which already computed every number for its health line and
GE_AUDIODBG trace; this only publishes them. FPS and TPS are deliberately not
copied into the snapshot - the overlay reads g_PortStatFps/Tps directly, so the
block cannot disagree with the strip it displaces.
Three things the field work changed:
- UNITS, on every field. The first cut printed window totals under a "/s"
(`SND 11008/11025 /s`), which reads as a rate and is not one. Samples are
now milliseconds of audio; CLK is milliseconds rather than samples x1000;
DEV carries Hz.
- PMP. portAudioPump runs every scheduler iteration, not once per drawn
frame, so the call rate (~133k/s) dwarfs the useful work and says nothing
about sound. The row now shows PMP and RND - calls per second against the
ones that rendered anything (~130/s), which is the pair that makes an
over-serviced pump visible. The old `IN` field is gone: at sub-millisecond
intervals it read 0ms forever, a loop-speed number wearing an audio label.
- One theme per row, six rows. FPS/TPS, PMP/RND, QUE/CAP, SND, the counters,
and STALL/CLK/DEV - previously the first row mixed three unrelated
measurements and the odd `130r` suffix had no unit at all.
And one bug found by running it rather than reading it: the window accumulators
were fed only while the overlay was on, so `cl_audio_log 1` on its own wrote a
file of plausible-looking zeroes. Both consumers now feed the window.
The log then gained two things the screen deliberately keeps out:
- EXACT FIGURES ALONGSIDE THE READABLE ONES. `snd` carries the raw sample
counts as well as ms, and `que` carries ms of audio as well as bytes. One
sample is 0.045 ms, so the ms form rounds away anything under ~22 samples -
a slow leak of a few samples per pump is invisible on screen and obvious in
the counts. Queue in ms answers the other question a user actually asks
("why does the sound lag?") in the unit they experience. Both forms sit in
the line, so a log line still reads beside a screenshot.
- A WALL-CLOCK START STAMP in the header, with t= per line left relative.
Nothing else the game logs carries a clock, so without this a report says
"it happened at t=45000ms" and that cannot be tied to a crash.log mtime, a
`ge: stats` line, or a user's "about 21:40". Wall clock on every line was
rejected on purpose: repetition, and a mid-run clock correction would make
the file read out of order, whereas relative t= cannot go backwards.
The block is gated per row on dynGetFreeGfx2, using the console's own 12-Gfx-
per-glyph rule. It is ~130 glyphs where the FPS strip draws two or three; a
cramped frame now shows a short block instead of overrunning the display list.
Weak stubs in libultra_shim.c keep the headless test targets linking, since
native_player.c already links bondview2.c.
tools/console_help.txt is the user-facing manual: what the pump is, how each
field reads, which values are healthy, the FPS-counter interaction, why the log
carries more units than the screen, what the timestamps mean, and what to
attach to a sound report. It ships beside the executable like
midas_ini_help.txt (CMake copy) and is listed in the readme's file table with
the log.
Verified on macOS: ctest 8/8; live runs through the GE_CONCMD harness with both
commands enabled, the log producing one line a second with correct values
(pmp ~147k/s, rnd ~130/s, que 3556/3520B 40/40ms, snd 11008/11025smp 499/500ms,
clk 0ms, all counters zero) under a `# started 2026-10-25 00:29:24` header. The
on-screen block was checked against three screenshots, including the target
easing back from its ratcheted 4544 B to the 3520 B floor and the FPS digits
disappearing when both overlays are on. Not verified on Windows or Linux.
Author
|
My latest commit added initial debug/log functionality to the audio-pump. (needs proper review/testing) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Goal
Closes #82: crackle/pop and slow music on 1.0.2b, reported on Windows 11 with a
USB DAC, present continuously from the intro onward.
The reporter's "from the intro on" detail is what makes this diagnosable. It
rules out anything event-driven (gunfire cadence, sound pools) and anything in
the mixing path (a wrong ADPCM decode sounds wrong, not choppy). It leaves the
delivery path: the pump that feeds SDL.
While chasing it I found the same root cause behind an older complaint — audio
arriving ~100 ms late, noticed when comparing weapon-reload timing against real
hardware. Both are one design problem, so this PR fixes both.
Description
The problem with 1.0.2b's pump
The pump in 1.0.2b (
port/src/audio_pc.c) takes its render count from thesimulation's field counter (
src/game/frametiming.c,currentFrameCounter) andcorrects an over-full queue by skipping whole rendered field frames. Two
consequences, both audible:
alAudioFrame()is the only thing that advances the game's audio clock. TheSFX player's event queue and the three music sequencers all move in rendered
samples (
src/libultra/audio/synthesizer.c:143-227). So the render count issimultaneously "how much audio" and "how fast audio time runs". Tying it to
the game loop means every stalled frame — stage load, the intro swirl, a
window drag, alt-tab — delivers no audio, the device drains, plays silence,
and resumes with a discontinuity. That is a click. Often enough, it is
continuous crackle and music running behind real time.
It also made audio behaviour depend on the
maxfpssetting: at uncappedrates the field delta is usually 0, so only the queue-low branch rendered at
all — which is Gen 1 behaviour again.
Dropping a rendered frame throws away ~16.7 ms of synthesised audio
mid-stream. That is a step discontinuity by construction, and the N64 never
did it:
src/audi.c:529sized each frame from the AI's remaining buffer(
(g_FrameSize - (osAiGetLength() >> 2)) + 16 + EXTRA_SAMPLES) — asample-level servo whose correction was inaudible. The port lost that because
portAudioPump()callsalAudioFrame()directly and the shim stubsosAiGetLength()to 0.The fix: split one decision into the two it always was
then equals the DAC's consumption rate: the queue is stable, device clock
drift is absorbed, and audio time is the hardware's own time. This is exactly
what
amHandleFrameMessagedid. The correction is a few samples of framelength, so nothing is ever dropped or duplicated.
those samples are discarded. Game audio time still advances, so a missing or
wedged sink cannot freeze the sequencers or saturate the SFX queue.
Also in this PR
10 s window (10.2 KB ceiling).
shallower than the interval between pumps starves by construction: between two
pumps the device drains that interval and refill stops at the target. This is
what makes 40 ms safe at 60 fps (interval 16.7 ms, floor never binds).
the pump runs N times a second and must bridge 1000/N ms, so the queue cap now
scales with the interval — a fixed 16 KB holds 185 ms, which is less than one
interval at 5 fps, so starvation was guaranteed. The per-pump render budget
scales too. Down to 1 fps.
portAudioPumpForce()no longer floods the queue.music.c's track-stopspins (
portCSPWaitStopped,portMusicKillAll) can force up to 512 frames ina few ms; that used to push seconds of audio into a 186 ms buffer, with the
excess dropped and the trim left to clean up — the intro's crackle. A forced
frame now advances the sequencer and is queued only when the queue is
genuinely shallow.
alAudioFrame, not after. Rendering aframe that cannot be queued is not free: it advances the audio clock and burns
CPU. (A reviewer caught this one after an earlier revision of this branch had
it backwards.)
port/src/audio_pace.c+port/include/audio_pace.h— no SDL, no engineheaders, no globals, no clock reads — driven by
port/tests/audio_pace.cunder
GE_PORT_TESTS=ON(ctest -R audio_pace).GE_AUDIODBG=1prints a one-second trace (pumps, samples rendered vsneeded, queue open→close vs target, clock credit, stalled, forced) so the next
audio report can be read instead of guessed at. The existing 10 s health line
is unchanged and only prints when a counter is non-zero.
Fixes #82.
Type of Change
How Has This Been Tested?
Unit / policy tests
or standalone, since the unit has no dependencies:
16 cases, all pure-function or closed-loop simulation:
test_refill_is_aligned_and_clamped,test_refill_does_not_drain_when_pumping_fast— alignment, one-frame cap,and the rounding-down trap below, isolated.
test_clock_holds_wall_time,test_clock_at_field_rate_is_exact— ratetracks wall time with no device (1 ms and 16.67 ms intervals, 60 s).
test_clock_catchup_is_bounded,test_clock_rests_while_a_device_paces— stall not replayed; phantom backlog discarded when a device takes over.
test_simulation_holds_the_queue,test_simulation_refills_a_dry_start— the constant-of-integration bug,and recovery from a device that opens empty.
test_simulation_absorbs_device_drift— ±100 ppm and ±2000 ppm crystalerror absorbed by the queue, sink never starves.
test_simulation_at_low_frame_rate,test_simulation_at_single_digit_frame_rate,test_simulation_at_high_frame_rate— 1, 2, 5, 8, 20, 60 and 300 fps.test_interval_floor_and_cap_scale_together— the invariant a fixed capbroke at 5 fps, for every rate 1–120 fps; a 60 s hitch is clamped.
test_target_ratchets_and_eases— ratchet bounded, easing returns to thefloor and never below.
test_drain_watchdog,test_simulation_survives_a_stall_between_pumps—"consuming" vs "frozen backlog" vs "pinned empty", and bounded recovery.
ctest: 8/8 (the 7 existing native suites plusaudio_pace).Real-binary verification (macOS/CoreAudio, 22050 Hz, 256-sample period)
Ran the built game on the title screen (
stage 90, the attract loop) for 24 swith
GE_AUDIODBG=1. This is deliberately where the reporter's crackle starts.Before (this branch's first revision, and 1.0.2b behaves worse):
queue cycling 1216→3584 B against a 10240 target it had ratcheted to.
After:
Queue steady at 3556 B against a 3520 target, render rate tracking what wall
time needs, and no
ge: audio: last N s:line at all — zero dry pumps, zeroover-cap drops, zero skipped frames for the whole run.
Two bugs found by running it, not by reasoning
Both now have tests named after them. Recording them because they are the kind
of mistake that passes review:
same budget (credit time, add a bounded bias, charge it back). It compiled and
passed 15 cases including a closed-loop simulated device, and starved on
hardware: if total rendered must equal wall time while the device also
consumes wall time, the queue depth is a constant of integration. A bounded
bias changes when samples arrive, never the running total.
every pump — ~11 % short at 300 fps, draining the queue whatever the target.
Not verified
cause there is unfixed by this PR and is deliberately left to its own change:
the device is still opened at an exact 22050 Hz with
allowed_changes = 0, soWindows shared mode (48000) puts an OS resampler and possibly a fallback
driver between us and the DAC. See below.
classic/demo-mode session at 1–8 fps.
Ways I tried to break it
maxfpsuncapped / 120 / 60 (tickhzderived changes the pump rate).mute_unfocusedzeroes the frame downstream of pacing).demand an absurd backlog.
Known limits, left for their own changes
want.freq = 22050,allowed_changes = 0. OnWindows this likely lands on the OS resampler or DirectSound/WinMM with mixer
quanta near our 11.6 ms device period. The fix is
SDL_AUDIO_ALLOW_FREQUENCY_CHANGE+ anSDL_AudioStream(22050 → devicerate). Kept separate on purpose: bundled together, a "still crackling" report
would be ambiguous. Full briefing in
docs/port/rate_prompt.md.Before spending time there: ask for the log line
ge: audio: opened the default device on driver 'X': N Hz, .... IfNis22050 the OS accepted the exact rate and that change is not the fix.
because they advance the sequencer without being delivered when the queue is
not shallow. Bounded by the stop. The real fix is wall-clock pacing
music.c's spins rather than iteration-counting them.500 ms, because the pump runs twice a second. Inherent to pumping from the
render loop; escaping it needs a separate audio thread, which the AL
library's shared state makes a much larger change. Continuous audio at high
latency beats crackle at low latency.
Checklist
(policy; real-binary verified on macOS at 60 fps — Windows untested)
(each non-obvious decision says what was tried and why it failed)
(single commit;
docs/notes excluded by.gitignore— forced in, sorry)(clean
-Wall -Wextraon the new files; the macOSldsection-alignmentwarning is pre-existing and unrelated)
Reviewer's guide — where to look
port/include/audio_pace.h— the whole design rationale in one commentblock, including the constant-of-integration trap.
port/src/audio_pace.c— ~220 lines, three functions.portAudioPaceRefill(device-paced) and
portAudioPaceFrame(both paths) are the core.port/tests/audio_pace.c— the cases namedtest_simulation_holds_the_queueandtest_simulation_at_single_digit_frame_rateare the two bugs from above.port/src/audio_pc.c—portAudioPump()only. Everything else in the file(the
aspMaininterpreter) is untouched.