Skip to content

fix(audio): stretch each frame's audio over the time the frame took - #257

Open
doublegate wants to merge 3 commits into
mainfrom
fix/audio-hiccup-root-cause
Open

fix(audio): stretch each frame's audio over the time the frame took#257
doublegate wants to merge 3 commits into
mainfrom
fix/audio-hiccup-root-cause

Conversation

@doublegate

@doublegate doublegate commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Motivation

Task #56: "audio plays ~1s, then ~1s silence, repeating forever."

audio.rs already named the cause — supply is fps / 60 — and had never measured it. It also repeated the ~1 s period as fact and guessed the period was the ring's 0.25 s capacity. This measures all three, fixes the one that is real, and ships the policy the maintainer chose.

The fix: stretch, don't chop

emu_thread::AudioServo stretches each emulated frame's audio across the wall-clock interval that frame actually took. The samples are the same samples — nothing can manufacture the missing 86% — they are spent over the whole frame instead of a sixtieth of it followed by silence. A slow core now sounds like a slow tape rather than a stutter, which was the explicit choice between the three available failure modes.

term source why
feed-forward EMA of the measured interval between produced frames correct on the first frame; an occupancy integrator covering a 7x range takes seconds
trim ring occupancy against half-full removes drift the feed-forward term cannot see, because it does not know what is banked

Clamped [1.0, 12.0]. Never compresses below real time — a core faster than 60 FPS already oversupplies, and drop-oldest is the right answer there.

Measured, same harness as the defect

before after
callbacks fully fed 0 / 469 412 / 469
samples delivered 14.2% 90.9%
...steady state (second half) 14.2% 100.0%
silent runs 86, mean 95 ms 14, mean 52 ms
first fully-fed callback never #15 (320 ms in)
pacer frames / 10 s 85 85
UI emu-lock p50 664 ns 590 ns

Steady state is 100%; the whole-window 90.9% is the startup ramp from an empty ring, which the harness now reports separately because one figure charges a transient to continuous quality. Throughput and UI latency are unchanged — this trades nothing.

What the investigation refuted

Two recorded claims did not survive, both of which had been written down as fact:

  • The emulated AI was suspected of gapping. It does not: 108/120 frames audible (the 12 contiguous, at startup), 3 underruns, staging exactly rate / 60 per frame. That mattered — it would have been an emulation defect with a different fix.
  • The "~1 s" period was attributed to the ring's 0.25 s capacity. Measured period is ~95 ms, set by the pacer. Both halves of that guess were wrong.

One tempting fix, built and rejected

The pacer sleeps a full frame period after every frame while ~85 ms behind (frame_bench 99.955 ms unpaced vs 117.6 ms paced). Shrinking it to 2 ms measures 1.14x A-B-A — larger than the entire declined optimization backlog — and takes the UI's median emu-mutex wait from 664 ns to 76.5 ms. The yield is load-bearing, not vestigial. Reverted; docs/audio.md records it so it is not rediscovered.

The architectural unlock stays open: emu_thread's header claims "the winit thread never takes the emu mutex", and app.rs takes it in six places.

Discipline notes

  • TRIM_GAIN is measured, not tuned. 0.2 / 0.4 / 0.6 all reach 100% steady state and all reach their first fully-fed callback at feat(cpu): the COP0 register file (T-12-001) #15; they differ only in the ramp-dominated whole-window figure. The lowest ships — picking 0.6 for a 1.3-point edge on a transient would be tuning.
  • The wiring test seeds the destination. A ROM-less core legitimately asks for 1.0, which is also the default, so asserting the value cannot distinguish "applied" from "never written". It seeds the core with a value outside the clamp range instead. Both mutations verified red: deleting set_audio_stretch, and never consulting the servo.
  • Determinism untouched. The stretch applies in the frontend resampler, which ADR 0004 already designates as the non-deterministic host-timing stage. The core's emitted stream is unchanged, so audio_play_rom and mixer_microcode are unaffected; with ring: None the servo never runs and behavior is byte-identical.

Gates

cargo fmt --check, clippy --workspace --all-targets -D warnings, cargo test --workspace (855 pass), rustdoc -D warnings, check_en_us.sh, markdownlint — one conditional, ALL-GATES-OK.

…ting it

audio.rs already named the cause -- supply is fps/60 -- and had never
measured it. It also repeated the user's "~1 s on / ~1 s off" as fact and
guessed that the period was the ring's 0.25 s capacity. Two probes now
settle all three claims, and only the first survives.

examples/audio_probe.rs (core side, before the ring): the emulated AI
stream is CONTINUOUS -- 108/120 frames audible, the 12 silent ones
contiguous at the start, 3 underruns -- staging exactly rate/60 samples
per frame, supply 26.1% against fps/60 = 26.1%. This refutes the
competing hypothesis that the game's audio DMA starves inside the
machine, which would have been an emulation defect rather than a speed
consequence.

measure_audio_gaps_at_the_device_boundary (device side, after the ring):
0/469 callbacks fully fed, 14.2% of samples delivered, silent runs mean
95 ms and max 128 ms. So the chop is ~10 Hz, an ORDER OF MAGNITUDE off
the reported ~1 s, and its period is one emulated frame plus the pacer's
yield -- not the ring capacity. Both halves of the recorded guess were
wrong and neither had been checked.

Also records a tempting fix that MUST NOT be taken. The pacer sleeps a
full frame period after every frame while ~85 ms behind (99.955 ms
unpaced via frame_bench, 117.6 ms paced). Shrinking it to 2 ms measures
A-B-A at 1.14x with audio 14.2% -> 16.2% -- and takes the UI's median
emu-mutex wait from 664 ns to 76.5 ms. The yield is load-bearing, not
vestigial: it is the only window in which anything else can take the emu
mutex, because the pacer holds it for the whole frame. Reverted.

No production code changes. The fix is a policy choice (slow-running
audio) plus an architectural one (stop app.rs taking the emu mutex in six
places, which emu_thread's own header already claims it does not).
Copilot AI review requested due to automatic review settings August 1, 2026 16:29
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@doublegate, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 91164c82-5c7a-47a1-bc8e-f11104af5e57

📥 Commits

Reviewing files that changed from the base of the PR and between 8340a46 and 77f6710.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • crates/rustyn64-frontend/examples/audio_probe.rs
  • crates/rustyn64-frontend/src/audio.rs
  • crates/rustyn64-frontend/src/emu.rs
  • crates/rustyn64-frontend/src/emu_thread.rs
  • docs/audio.md
📝 Walkthrough

Summary by CodeRabbit

  • Documentation

    • Added measured audio diagnostics covering emulated playback continuity, device delivery gaps, silent-run durations and buffering limitations.
    • Updated audio documentation with observed production and delivery rates, plus guidance on pacing and UI-operation constraints.
  • Tests

    • Added an optional end-to-end audio measurement test that reports underruns, timing, mutex delays, pacing activity and playback gaps.
  • Tools

    • Added a standalone audio diagnostic that analyses frame timing, audible coverage, underruns and silent-gap timelines.

Walkthrough

The PR adds standalone and end-to-end audio probes. It measures emulator production, device delivery, underruns, pacing, mutex waits, and silent gaps. It updates frontend and user documentation with the measured results.

Changes

Audio measurement

Layer / File(s) Summary
Standalone audio probe
crates/rustyn64-frontend/examples/audio_probe.rs
The probe loads a ROM, captures 120 frames of audio, calculates timing and supply metrics, and reports audible and silent runs.
End-to-end audio measurement
crates/rustyn64-frontend/src/emu_thread.rs
The ignored test runs EmuThread with AudioRing, consumes simulated device buffers, and reports underruns, delivery, pacing, and mutex latency.
Measured findings and pacing documentation
crates/rustyn64-frontend/src/audio.rs, docs/audio.md
The documentation records continuous emulated production, sparse device delivery, pacer behaviour, unused occupancy control, and mutex contention measurements.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 8 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Measured, Never Tuned ⚠️ Warning The PR adds unproven timing/threshold values: SILENCE_FLOOR=1e-4, MAX_WARM=300, BUF=2048, OBSERVE=10s, and reports 95/128 ms, with no ledger entry or manual/wiki citation. Record each value and its measurement method in docs/accuracy-ledger.md, or cite an authoritative manual/wiki source; justify the silence threshold and callback protocol before using the results as evidence.
Title check ⚠️ Warning The title uses valid Conventional Commit syntax, but it claims an audio fix that is absent from the changeset, which adds probes and documentation. Rename it to describe the measured audio probes and documentation, or include the claimed production change in the changeset.
✅ Passed checks (8 passed)
Check name Status Explanation
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.
Oracle Number Is Stated ✅ Passed PASS: the diff adds an example, documentation, and an ignored #[cfg(test)] measurement; it changes no emulation behaviour, so the oracle-number requirement is not applicable.
Docs-As-Spec Sync ✅ Passed The diff changes no rustyn64-cpu/rsp/rdp/audio/cart/core files or behaviour; it adds frontend probes/tests and updates docs/audio.md. The PR body states no production changes.
Changelog Entry For User-Visible Changes ✅ Passed The PR adds only an opt-in diagnostic example, an ignored measurement test, and documentation; it changes no shipped behaviour or user-facing feature.
Unsafe Stays Out Of The Chip Crates ✅ Passed The PR diff adds no unsafe tokens or unsafe AST nodes, changes no forbid(unsafe_code) attribute, and all Rust changes are in rustyn64-frontend.
Description check ✅ Passed The description covers the audio measurements, pacing investigation, and documentation, so it is related to the changeset.

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

@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: 5

🤖 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 `@crates/rustyn64-frontend/examples/audio_probe.rs`:
- Around line 79-80: Update main and the audio probe error flow so
load_rom(&raw) propagates a typed error instead of calling panic! on invalid ROM
data. Preserve the existing path context in the returned error, and ensure
main’s return type supports propagation of the load failure.
- Line 122: Update the underrun calculation in the audio probe to capture
audio_underruns() before the timed-frame loop and subtract that baseline from
the final underrun count, rather than subtracting the first frame’s underruns
via log.first(). Preserve the existing log.last() final-count behavior and
include underruns from the first timed frame.

In `@crates/rustyn64-frontend/src/emu_thread.rs`:
- Around line 407-415: Update the documentation around EmuThread and OBSERVE to
report approximately 10 Hz chopping with mean silent runs of 95 ms and a maximum
of 128 ms, while separately describing the measured gap period rather than
claiming a “~1 s on / ~1 s off” cycle. Replace the assertion that audio supply
is exactly fps / 60 with wording that identifies it as a measured ratio and
preserves uncertainty.
- Around line 431-433: Update the documentation above the BUF constant to
describe 1024 frames as a representative callback size and remove the claim that
it is cpal’s usual default, while preserving the existing cadence context.

In `@docs/audio.md`:
- Around line 246-252: Update the measurement documentation around the audio
probes to record each probe’s absolute or differential observation, ROM revision
and hash, host platform, and complete build configuration. Preserve both
existing commands, adding the provenance needed to reproduce their reported
values before presenting the measurements.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 574a852e-1e37-450b-abe0-0a1efc3ccc6d

📥 Commits

Reviewing files that changed from the base of the PR and between 8dcd1f0 and 8340a46.

📒 Files selected for processing (4)
  • crates/rustyn64-frontend/examples/audio_probe.rs
  • crates/rustyn64-frontend/src/audio.rs
  • crates/rustyn64-frontend/src/emu_thread.rs
  • docs/audio.md

Comment thread crates/rustyn64-frontend/examples/audio_probe.rs Outdated
Comment thread crates/rustyn64-frontend/examples/audio_probe.rs Outdated
Comment thread crates/rustyn64-frontend/src/emu_thread.rs
Comment thread crates/rustyn64-frontend/src/emu_thread.rs Outdated
Comment thread docs/audio.md

Copilot AI 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.

Pull request overview

This PR documents and codifies a re-runnable investigation of the frontend audio “chopping” symptom by adding two measurement probes (core-side and device-boundary) and updating the audio documentation/comments to reflect measured behavior rather than inferred claims.

Changes:

  • Add a committed probe example (audio_probe.rs) to measure whether gaps originate in the emulated AI stream (pre-ring).
  • Add an ignored measurement test in emu_thread.rs to quantify underrun/chopping at the simulated device callback boundary (post-ring), including UI mutex-wait impact context.
  • Update docs/audio.md and the audio.rs module docs to record the measured results and correct the previously repeated (but non-reproducing) ~1s period claim.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
docs/audio.md Adds a “measured at both boundaries” section with run instructions and summarized probe results.
crates/rustyn64-frontend/src/emu_thread.rs Introduces an ignored measurement test to observe device-boundary gap period and prints summary stats.
crates/rustyn64-frontend/src/audio.rs Updates module-level documentation to cite the new measurements and correct prior asserted claims.
crates/rustyn64-frontend/examples/audio_probe.rs Adds a runnable probe example that measures emulated-AI continuity and supply ratio pre-ring.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// tail on underrun, so trailing silence IS the underrun — but a
// genuinely quiet passage also reads as zero, which is why the core
// probe establishes separately that the stream is not silent.
let real = buf.iter().rposition(|s| *s != 0.0).map_or(0, |i| i + 1);
The device was offered fps/60 of what it consumes and silence for the
rest -- 14.2% measured, with 0 of 469 callbacks fully fed. produce_audio
stages exactly one emulated frame of audio per emulated frame, so no
buffering strategy can make up the difference.

AudioServo stretches that frame's audio across the wall-clock interval
the frame actually took: feed-forward from an EMA of the measured
interval, trimmed by ring occupancy toward half-full, clamped to
[1.0, MAX_AUDIO_STRETCH]. The samples are the same samples, spent over
the whole frame rather than a sixtieth of it, so a slow core sounds like
a slow tape instead of a stutter. The maintainer chose continuity over
pitch when given the three options.

14.2% -> 90.9% delivered, 100.0% in steady state; the shortfall is the
startup ramp from an empty ring, which the harness now reports separately
because a single figure charges a one-off transient to steady quality.
Frames per second (85) and UI emu-lock latency (p50 590 ns) are both
unchanged -- this trades no throughput.

TRIM_GAIN is measured, not tuned: 0.2/0.4/0.6 all reach 100% steady state
and all reach their first fully-fed callback at #15, differing only in
the ramp-dominated whole-window figure. The lowest ships, because a
larger gain makes the pitch hunt and bought nothing.

The wiring test seeds the core's stretch with a value outside the servo's
clamp range, because a ROM-less core legitimately asks for 1.0 -- which
is also the default, so asserting the value alone cannot tell "applied"
from "never written". Both mutations verified red: deleting
set_audio_stretch, and never consulting the servo.

Determinism untouched: the stretch applies in the frontend resampler,
which ADR 0004 already designates as the non-deterministic host-timing
stage. The core's emitted stream is unchanged, so audio_play_rom and
mixer_microcode are unaffected, and with no ring the servo never runs.
@doublegate doublegate changed the title test(audio): measure the chopping at both boundaries instead of asserting it fix(audio): stretch each frame's audio over the time the frame took Aug 1, 2026
Four review findings, three of which are real defects in the measurement
rather than in the code it measures.

`rposition(|s| *s != 0.0)` treated an exact zero at the tail of a
callback as the start of underrun silence. Real audio crosses zero, so
some fully-fed callbacks were scored as partial. It biases DOWNWARD and
applied to both legs equally, so the 14.2% -> 90.9% comparison held, but
both absolute figures were pessimistic. Now thresholded at the same
1e-4 floor audio_probe.rs uses, and the numbers move to 14.2% -> 90.1%.

The underrun baseline was taken from the FIRST TIMED FRAME rather than
from before the loop, so any underrun that frame recorded was silently
dropped. Captured before the loop now. The documented value is still 3
for this ROM -- the method was wrong and the number happened not to be,
which is worth stating rather than quietly correcting.

"1024 frames -- cpal's usual default" is an undocumented claim about
cpal 0.18, which defers BufferSize::Default to the host and device. It
is a representative callback size and the comment now says so; the
delivered percentage is a ratio and does not depend on it.

docs/audio.md gains the provenance block its own rules require: ROM
SHA-256, host, toolchain, build configuration, tree, and the fact that
every figure is differential. It also states outright that the two
harnesses run different builds (fast-exec vs default) and must not be
cross-read -- the mistake the table exists to prevent.

Not adopted: making the env-var and file-read failures typed errors.
Those are harness misconfiguration, not untrusted input, and a probe
that cannot start must stop loudly. The ROM CONTENTS are parsed data and
now do return a typed error, which is where the rule actually bites.
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication — CodeRabbit + Copilot

Four adopted, one rejected with reasoning, all in 77f6710. Three of the four are defects in the measurement itself, not in the code it measures — which is the worse kind, because a wrong measurement is what the whole PR is offered as evidence for.

!= 0.0 misclassifies a waveform zero crossing — adopted (Copilot)

Correct, and it is the sharpest finding here. rposition(|s| *s != 0.0) treats an exact zero at the tail of a callback as the start of underrun silence. Real audio crosses zero, so fully-fed callbacks were being scored as partial.

Now thresholded at the same 1e-4 floor audio_probe.rs uses. The numbers move:

before after
delivered, servo on 90.9% 90.1%
callbacks fully fed 412/469 404/469

The bias is downward and applied to both legs equally, so the 14.2% → 90% comparison was never in question — but both absolute figures were pessimistic and are now honest.

Underrun baseline off by one frame — adopted (CodeRabbit)

Right: log.first() is the first timed frame's count, taken after that frame ran, so any underrun it recorded was dropped. Captured before the loop now.

The number is unchanged — still 3 for this ROM. Worth saying plainly rather than quietly correcting: the method was wrong and the value happened not to be. Had a ROM underrun during that one frame, the documented figure would have been silently low.

"cpal's usual default" — adopted (CodeRabbit)

Also right, and it is this repo's own recorded failure mode: "undocumented is a claim about a document, and it decays." cpal::BufferSize::Default defers to the host and device in 0.18, so there is no single figure to call the default. The comment now says representative, and notes that the delivered percentage is a ratio and does not depend on the buffer size at all.

Provenance for the measurements — adopted (CodeRabbit)

docs/audio.md gains the block its own rules require: ROM SHA-256 17ce0773…, host (i9-10850K), toolchain (rustc 1.96.0), build configuration, tree, and that every figure is differential over a post-warm-up window.

It also states something the reviewer did not ask for but that the two tables made possible: the two harnesses run different buildsaudio_probe under fast-exec,fast-scheduler at ~15.7 FPS, the device test under default features at ~8.5 FPS. Reading a supply ratio from one against a frame rate from the other is a mistake the table now heads off.

Typed error for the ROM load — adopted in part, rejected in part

Adopted where the rule actually bites: load_rom parses ROM contents, and that now returns a typed error through main() -> Result<…>.

Rejected for the env var and the file read. Those are harness misconfiguration — "you did not set RUSTYN64_PROBE_ROM" — not untrusted input, and a probe that cannot start must stop loudly rather than return a value nobody will read. The distinction is now a comment in the file so the next reviewer does not have to re-derive it.

Gates

fmt / clippy -D warnings / cargo test --workspace / rustdoc -D warnings / check_en_us / markdownlint, one conditional: ALL-GATES-OK. Both harnesses re-run after the change; figures above are from that run.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

This PR introduces a host-side resampler servo (AudioServo) that dynamically stretches audio frame samples over measured wall-clock frame intervals to eliminate audio chopping on slow emulated cores.

Blocking issues

  • Incorrect interval observation during catch-up frame bursts (emu_thread.rs:L355-L360): Inside the while produced < due loop, servo.observe(now - last_frame) is executed for every catch-up frame. Because catch-up frames execute back-to-back in rapid succession without sleeping, iterations 2+ measure the frame execution latency (~1–2 ms) instead of wall-clock pacing. This corrupts AudioServo's moving average during catch-up bursts, collapsing audio_stretch toward 1.0 and causing audio stuttering right after lag spikes. The wall-clock interval observation should be updated once per pacer cycle rather than per catch-up sub-frame.
  • Breaking API change on public struct PacerStats: Adding pub audio_stretch_bits: AtomicU64 directly to PacerStats breaks struct literal instantiation for external downstream users. Add #[non_exhaustive] to PacerStats or keep non-public internal atomic fields private.

Suggestions

  • crates/rustyn64-frontend/src/emu.rs:L524-L525: In stretched_rate, (f64::from(output_rate) * s) as u32 relies on float-to-int truncation/saturation behavior. If output_rate is set to an unusually high custom rate where output_rate * MAX_AUDIO_STRETCH > u32::MAX, explicit clamping prior to casting will prevent edge-case saturation warnings or overflow.
  • crates/rustyn64-frontend/src/emu_thread.rs:L142-L146: In AudioServo::stretch, period.as_secs_f64() is checked against 0.0, but interval.as_secs_f64() is not validated for sub-zero or zero values before computing base.

Nitpicks

  • crates/rustyn64-frontend/examples/audio_probe.rs:L73-L80: main() returns Result<(), Box<dyn Error>>, yet env var retrieval and file reading use explicit panic! calls instead of returning typed errors.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

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