Skip to content

Audio: silent GetBuffer, 7.1.4 fold-down, ring-fill pacing - #180

Merged
willfaust merged 2 commits into
willfaust:mainfrom
spitefulowl:audio-render-fixes
Oct 4, 2026
Merged

willfaust merged 2 commits into
willfaust:mainfrom
spitefulowl:audio-render-fixes

Conversation

@spitefulowl

Copy link
Copy Markdown
Contributor

Why

Ori and the Will of the Wisps played nothing but crackle. Its Wwise renders through Wine's ISpatialAudioObjectRenderStream, and two problems added up.

Commits

  1. Hand out silence from GetBuffer and fold a 7.1.4 bed to stereo (ml1227).
    • spatialaudio.c mixes each static object into the render buffer with *out += *in but never clears it. WASAPI leaves GetBuffer contents undefined, and ios_get_render_buffer handed back the same scratch area every period, so every period was added onto the previous one. The source level of the 12-channel stream climbed from mean |x| 0.01 to 5. winecoreaudio and winepulse do not clear the buffer either, so upstream Wine has the same latent bug.
    • ios_get_render_buffer now zeroes the frames it hands out.
    • The stereo fold-down now honours channel masks up to 12 channels. The top speakers go to their own side at -3 dB and the top centres to both sides. Before, channels 9-12 were dropped.
  2. Wake the event client by ring fill, not by the clock (ml1230).
    • The game delivered 212 s of audio in ~230 s of gameplay: a 10 ms hole every ~120 ms. The spatial stream asks for exactly one period per event, and the timer thread signalled after every usleep(10000), so every late wake-up was 10 ms of audio never written.
    • The timer loop now keeps a lead of MADEIRA_AUDIO_LEAD_MS queued (default 60 ms, 10-500). Below the lead it signals again as soon as the client has written; at or above the lead it sleeps until the lead would be used up.
    • The timer thread runs under THREAD_TIME_CONSTRAINT_POLICY, as Core Audio's I/O thread does.
    • The RT callback counts short passes and clamped samples, and a [ios_audio] ml1230 pacing line is printed every ~10 s per live stream.
    • Null mode keeps the plain 10 ms beat.

Testing

  • ml1227, on the device (Ori and the Will of the Wisps, 2026-10-02): the source level stayed bounded (peak <= 0.63, no clipping) and the sound became recognisable. check-audio-downmix.py gains a 7.1.4 case, which fails before this change and passes after.
  • ml1230, on the device: clean sound by ear. 38 of 39 ten-second windows wrote 99.8-100.4% with 0 short passes. The one bad window was a 37 s load at 4-7 FPS, during which the game's own audio thread did not run.
  • ml1230 trade-off: latency grows to ~60 ms in the ring plus the RemoteIO buffer.
  • Load-order fix, built only: a fix in the pacing loop, made after the device build, is only syntax-checked. The loop now loads play_pos before write_pos and clamps the padding at 0.

Notes

No binary is included. libntdll_unix.a is built by build/ntdll-unix/build.sh.

🤖 Generated with Claude Code

spitefulowl and others added 2 commits October 3, 2026 23:31
Ori and the Will of the Wisps played nothing but crackle. Its Wwise
renders through Wine's ISpatialAudioObjectRenderStream: spatialaudio.c
takes the render buffer with IAudioRenderClient_GetBuffer and mixes each
static object into it with `*out += *in`, but never clears it. WASAPI
leaves GetBuffer contents undefined, and ios_get_render_buffer handed back
the same scratch area every period, so every period was added onto the
previous one. The [ios_audio] ml1065 source level of the 12-channel stream
climbed from mean|x| 0.01 to 5 with peaks of 36. (winecoreaudio and
winepulse do not clear the buffer either; upstream Wine has the same
latent bug.)

- ios_get_render_buffer zeroes the frames it hands out.
- The stereo fold-down honours a channel mask up to 12 channels
  (mix_gain[12][2], mixer, attach log and source statistics loop to 12):
  the top front/back left/right speakers go to their own side at -3 dB,
  the top centres to both sides at -3 dB. Before, a stream with more than
  8 channels got the default 7.1 layout and channels 9-12 were dropped.
- The leftover-channel comment no longer claims the top speakers are not
  placed.

Tagged ml1227.

Verified on the device (Ori and the Will of the Wisps, 2026-10-02): the
ml1065 level stayed bounded (mean|x| 0.0005-0.01, peak <= 0.63, no
clipping) and the sound became recognisable; the crackle that remained was
gaps, fixed separately by the event pacing change.
tests/host/check-audio-downmix.py gains a 7.1.4 case (fails before this
change, passes after).

Rebuild app/Madeira/libntdll_unix.a with build/ntdll-unix/build.sh (the
archive is not committed).

Signed-off-by: spitefulowl <spitefulowll@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the GetBuffer fix Ori and the Will of the Wisps' sound was
recognisable but still crackled: the game delivered 212 s of audio in ~230
s of gameplay, a 10 ms hole every ~120 ms, ~89 GetBuffer calls a second
instead of 100. Wine's ISpatialAudioObjectRenderStream (which Wwise renders
through) asks for exactly one period per event, and ios_timer_loop
signalled the event after every usleep(10000). Every late wake-up of that
thread was 10 ms of audio never written, so the ring ran dry and the device
played a gap. The same holds for any client that writes a fixed period per
event.

ios_timer_loop now paces by ring fill for live streams (null mode keeps the
plain 10 ms beat):
- It keeps a lead of MADEIRA_AUDIO_LEAD_MS (default 60 ms, 10..500, at
  most the ring minus one period) queued. Below the lead it signals again
  as soon as the client has written since the last signal, or after 10 ms
  if it has not, polling every 1 ms; at or above the lead it sleeps until
  the lead would be used up (1-10 ms), on mach_wait_until deadlines.
- The timer thread runs under THREAD_TIME_CONSTRAINT_POLICY (period 10 ms,
  computation 0.5 ms, constraint 5 ms), as Core Audio's I/O thread does.
- The RT callback counts per stream the frames it asked for and did not
  get, short passes, passes and the largest pass (relaxed atomics), and
  device-wide the samples clamped to +-1. Every ~10 s per live stream:
  `[ios_audio] ml1230 pacing stream=... client wrote X s of audio in Y s`.

Fixed relative to the device build: the loop loaded write_pos before
play_pos, so a render pass between the two loads made wr - play wrap to
~2^64; the loop then slept 10 ms exactly when the ring was low. It now
loads play_pos first and clamps the padding at 0 (a reset stores the two
positions one after the other).

Tagged ml1230. docs/madeira.cfg.example documents the lead; the config
catalog lists env.MADEIRA_AUDIO_LEAD_MS.

Verified on the device (Ori and the Will of the Wisps, 2026-10-02, the
build without the load-order fix): clean sound by ear; 38 of 39 ten-second
windows wrote 99.8-100.4% with 0 short passes, ring 1856..3328 frames,
worst late wake <= 0.1 ms. The one bad window was a 37 s load at 4-7 FPS in
which the game's audio thread itself did not run. Latency grows to ~60 ms
in the ring plus the RemoteIO buffer. The load-order fix is built only
(syntax-checked with the build/ntdll-unix/build.sh flags).

Rebuild app/Madeira/libntdll_unix.a with build/ntdll-unix/build.sh (the
archive is not committed).

Signed-off-by: spitefulowl <spitefulowll@gmail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@willfaust
willfaust merged commit aea693b into willfaust:main Oct 4, 2026
willfaust added a commit that referenced this pull request Oct 4, 2026
- metal-validation (#182) is read through madeira_cfg.h, so the settings
  catalog lists it.
- A game's fastsync semaphore choice (#181) is exported only when the game
  made one, so madeira.cfg's env.MADEIRA_FASTSYNC_SEM still applies;
  MADEIRA_CPU_COUNT and DXMT_D9_ANISO_LIMIT are unset when a game does not
  choose them, so a previous game's value never beats madeira.cfg.
- The JIT-pool dump (#179) stays on by default, sparse; MADEIRA_JIT_DUMP=0
  turns it off. The 256 MB pool choice says large games can run short.
- MADEIRA_AUDIO_LEAD_MS=0 (#180) restores the plain 10 ms beat without the
  time-constraint policy; a stream that leaves two wake-ups unanswered is
  polled on the 10 ms beat instead of every 1 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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