Skip to content

Refuel beep plays with uneven rhythm ("triplets") #1

Description

@morozov

Affected platform: macOS

Problem

While the player is refueling, the refuel beep should fire at a steady cadence. Instead, the beeps are heard in an irregular rhythm, perceived as a "triplet" pattern — some pairs of beeps sound closer together, others further apart.

The trigger itself is regular: log timestamps confirm rewindAndPlay is called at consistent ~83 ms intervals. The irregularity is in playback, not in triggering.

The issue reproduces with a minimal Ebiten program that simply calls Rewind()+Play() on aт *audio.Player every Update() at 12 TPS.

Research

Audio pipeline on macOS:

rewindAndPlay()
  → mux pre-buffer filled (~14.6 ms beep + EOF)
  → next AudioQueue render callback (fires every 34.83 ms, independent clock)
  → buffer enqueued at tail of 4-deep hardware pipeline
  → plays 3 × 34.83 ms = 104.5 ms later

The Darwin AudioQueue driver in ebitengine/oto uses 4 ring buffers of 12 288 bytes each.
At 44 100 Hz stereo float32:

12288 bytes / (2ch × 4 bytes/sample) / 44100 Hz ≈ 34.83 ms per buffer

The render callback fires every ~34.83 ms on the hardware clock, independently of the game clock.
When rewindAndPlay is called, the beep data enters the pipeline at the next render callback —
anywhere from 0 to 34.83 ms later. That callback's output is then 3 buffers deep in the hardware
queue, so actual playback happens 104.5 ms to 139.3 ms after the trigger.

Why the rhythm is uneven:

The game fires at 83.33 ms (12 TPS); the render callback fires at 34.83 ms. Their ratio is
irrational (≈ 2.392), so the phase between the two clocks drifts by:

83.33 mod 34.83 ≈ 13.67 ms per frame

This means the "wait until next render callback" after each game trigger cycles through different
values each frame. The resulting playback spacings alternate between approximately:

  • ~69.6 ms (2 render periods) — two beeps sound close together
  • ~104.4 ms (3 render periods) — two beeps sound far apart

The pattern repeats approximately every 5 game frames / 12 render callbacks (~415 ms), which the
ear perceives as a triplet-like rhythm.

Why Rewind() does not help:

Rewind() calls mux Seek(), which clears the in-memory pre-buffer (p.buf). It cannot touch
AudioQueue buffers already committed to hardware via _AudioQueueEnqueueBuffer. The jitter is
introduced by the hardware buffer granularity, not by the pre-buffer.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions