Skip to content

Fix audio cut-outs: re-anchor on time line discontinuities and phase-lock the proxy clock - #74

Open
vlasky wants to merge 2 commits into
briankendall:masterfrom
vlasky:fix/clock-sync-and-timeline-discontinuity
Open

vlasky wants to merge 2 commits into
briankendall:masterfrom
vlasky:fix/clock-sync-and-timeline-discontinuity

Conversation

@vlasky

@vlasky vlasky commented Aug 29, 2026

Copy link
Copy Markdown

Fixes #14, #19, #43 and (I expect) #62. Supersedes #66; see the comparison at the end.

Summary

The "audio randomly goes silent until CoreAudio is restarted" problem is two separate bugs with the same visible result. This PR fixes both at the cause, plus two client-counting bugs found on the way, and I've verified the main one in the act on my own hardware (M-series MacBook Pro, macOS 15, Dell U2723QE over DisplayPort).

Sudden cut-out Gradual cut-out
Symptom Audio dies instantly, typically after wake or display wake Distortion builds over minutes to hours, then silence
Log signature (1.0.7) Nothing (the overrun warning is suppressed on this path) ProxyAudio: output unexpected overrun every 5 s once broken
Cause coreaudiod restarts the target device's IO engine underneath our IO proc and the sample time line restarts from zero; inputOutputSampleDelta is never recomputed The proxy's synthetic clock runs open-loop at a slightly wrong rate and nothing corrects the accumulated position error

Root cause 1: the target device's time line restarts underneath us

outputDeviceIOProc reads from the ring buffer at inOutputTime->mSampleTime + inputOutputSampleDelta. The delta is computed once and only recomputed via resetInputData(), which runs on StartIO, on target device change and on sample rate change. There is a fourth event that none of those cover: coreaudiod stopping and restarting the target device's IO engine in place, same AudioObjectID, no property notification. It happens on display wake for HDMI/DisplayPort audio, when usbaudiod restarts its session for USB audio (on my machine that is every ~30 minutes), and when other processes change the device's sample rate or format. The sample times the IO proc receives then restart from zero, startFrame lands minutes behind the write position, Fetch() returns silence, and because startFrame < mStartFrame the overrun warning never fires. It stays that way until something calls resetInputData(), which is why switching devices or changing the buffer size "fixes" it.

Here it is happening with this branch installed (Debug build, log show), audio playing, after pmset displaysleepnow and a mouse wiggle:

20:13:51  Display is turned off
20:14:52  Display is turned on
20:14:53.000  HALS_IOEngine2::StopIO   (Dell DP audio, IO context 900)   <- same device, no deactivate/activate
20:14:53.025  HALS_IOEngine2::StartIO  (Dell DP audio, IO context 900)
20:14:53.225  ProxyAudio: target device time line discontinuity of -8148544 frames (sample time now 13688), re-anchoring
20:14:53.225  ProxyAudio: anchored inputOutputSampleDelta at 8200424, fill level 2880 frames, phase 2083.8 frames
20:15:10.837  ProxyAudio: clock servo: mean phase error -0.1 frames, integral -2, correction -0.1 ppm

The device had been running for 172 s (172 s x 48 kHz = 8.26 M frames) and its sample time snapped back to 13688. Audio continued without interruption. With 1.0.7 this is the moment it goes silent. The "silent after wake" reports are the same thing with a race: the HAL restarts the proxy's IO first (which re-anchors), then the display link comes up a few seconds later and restarts the target's engine, invalidating the fresh anchor.

Fix. Track the sample time each IO cycle should start at, on both the target side (expectedOutputSampleTime in outputDeviceIOProc) and our own side (lastInputFrameTime + lastInputBufferFrameSize in DoIOOperation), and re-anchor on any backward jump or a forward jump larger than kTimelineJumpToleranceFrames. This fires on the first cycle of the new time line and needs no threshold tuning: a backward sample time is unambiguous. A bounded fill-level check against the ring's real capacity remains as a last resort and has not fired in testing.

Root cause 2: the proxy clock was open-loop

GetZeroTimeStamp made each 16384-frame period nominal x rateRatio, where rateRatio was the mean of the target's mRateScalar samples collected since the previous call, and the accumulator was zeroed on every call rather than on every period. When no target IO cycle had run between two calls the ratio silently fell back to 1.0, so the proxy ran at some rate between nominal and the target's real rate. The residual is tens of ppm at most, but it integrates without bound, which matches "works for hours, then distorts, then dies" and matches the observation in #19 that "when proxied device is active" mode (which resets buffers whenever audio stops) helps.

Fix. Treat the proxy clock as what it is, a synthetic clock we control, and phase-lock it to the target:

  • The rate scalar feed-forward is only consumed when a period actually elapses, only when the HAL flags it valid (kAudioTimeStampRateScalarValid) and plausible.
  • The period length is additionally steered by a clamped PI correction (at most 500 ppm) on the phase error between the two clocks, measured by projecting our own time line onto the host time of each target IO cycle (proxySampleTimeForHostTimeNoLock). Measuring the ring buffer fill level instead does not work: the write pointer only moves once per input cycle, so the sampled fill carries a phase-dependent offset of up to one input buffer and the loop hunts (I hit that in the first iteration; it showed up as mean errors of exactly -256 and +256 frames with +-250 ppm swings).
  • No samples are ever dropped or duplicated. A virtual device whose rate moves by a few ppm is indistinguishable to clients from any real USB interface, so this is safe for pro audio use.
  • GetZeroTimeStamp now advances by however many periods have elapsed rather than at most one per call.

Measured behaviour over ~5 hours, across 3 sleep/wake cycles, a monitor power cycle and the display-wake restart above: phase error within +-0.3 frames, correction within +-0.3 ppm (the Dell's clock is ~3 ppm off nominal, target rate ratio 0.999997). The steady-state correction is small on this hardware precisely because the device is close to nominal; the point of the loop is that the residual is now bounded rather than integrated.

Also fixed: client counting in StartIO/StopIO

The HAL calls StartIO/StopIO once per client (visible in the log as StartIO, StopIO, StartIO within 50 ms when playback starts). StartIO called resetInputData() before checking the count, wiping the ring buffer under clients that were already playing. StopIO set inputFinalFrameTime for every client, and nothing clears it except resetInputData(), so a non-final client stopping would silence the output permanently at the startFrame >= inputFinalFrameTime early return. Both now act only on the first/last client. resetInputData() is called outside stateMutex to keep the lock order consistent with the IO proc (IOMutex, then stateMutex, then getZeroTimestampMutex).

Why this rather than #66

I want to be fair to #66: its instinct (re-anchor the delta) is right, and for the sudden class its check would have fired here too, one cycle later. But it is a symptom detector rather than a fix, and it leaves real gaps:

  1. It detects the fill level, not the event. The fill only exceeds its threshold when the read position has already fallen a long way behind. This PR detects the time line restart itself, on the first cycle, from a quantity (sample time going backwards) that cannot be a false positive.
  2. It only catches one direction. It explicitly ignores negative fill ("natural draining"). A forward jump of the target time line, or a backward jump of our own, sends the read position ahead of the write position: permanent silence with the overrun warning firing, which fix: auto-recover from audio cutout caused by stale inputOutputSampleDelta #66 never recovers from. The checks here are symmetric.
  3. It does not address the gradual drift at all. In the direction where the read pointer overtakes the write pointer (the failure @briankendall describes, and the one the overrun warning was written for) fix: auto-recover from audio cutout caused by stale inputOutputSampleDelta #66 never triggers. In the other direction it fires only after 16384 frames of extra latency have silently accumulated over hours, then skips 370 ms of audio. Drift is a clock problem and this PR fixes it in the clock, with no audible artefact.
  4. It has the ring capacity wrong. ringCapacity is passed kDevice_RingBufferSize (16384), which is the zero time stamp period, not the ring buffer's capacity (88200 frames). Harmless in practice, but it shows the check is not measuring what it says it is.
  5. It ships 1700 lines of diagnostics and tests into the driver tree for a fix that is a few dozen lines. The Debug-only clock servo log line in this PR (one DebugMsg every 64 periods) has proven sufficient to diagnose everything above from log show.

Review notes

  • Tuning constants are in ProxyAudioDevice.h (kProxyClockGainP, kProxyClockGainI, kProxyClockMaxCorrection, kTimelineJumpToleranceFrames) with the reasoning in the comment. P gives a ~24 s time constant, I is set for a damping ratio of ~0.7.
  • Re-anchor events log at LOG_NOTICE unconditionally (they are rare and are exactly what a user should paste into a bug report). The servo status line is DEBUG only.
  • inputOutputSampleDelta == -1 as a sentinel was replaced with an explicit inputOutputSampleDeltaValid flag; smallestFramesToBufferEnd (debug only) was removed.
  • The .gitignore change just ignores a local build-debug/ directory.
  • I built and tested the Debug configuration with ad-hoc signing on Apple silicon. I have not exercised a USB target, but the usbaudiod session restarts visible in my logs are the same in-place engine restart and should be caught by the same check; a report from someone with a USB interface (e.g. the Scarlett in fix: auto-recover from audio cutout caused by stale inputOutputSampleDelta #66) would be welcome.

To verify on any machine after installing a Debug build:

log show --last 12h --style compact --predicate 'eventMessage CONTAINS "ProxyAudio" AND (eventMessage CONTAINS "discontinuity" OR eventMessage CONTAINS "re-anchoring" OR eventMessage CONTAINS "out of range" OR eventMessage CONTAINS "overrun" OR eventMessage CONTAINS "anchored")'

A discontinuity ... re-anchoring line with audio continuing is the fix catching the old failure; out of range or overrun lines would be something new.

vlasky added 2 commits August 29, 2026 15:09
… proxy clock

Two distinct failure modes caused the long-standing "audio randomly goes
silent" problem (briankendall#14, briankendall#19, briankendall#43, briankendall#62, PR briankendall#66):

1. coreaudiod restarts the target device's IO engine underneath our IO proc
   without telling us (DisplayPort/HDMI audio when the display wakes, USB
   audio when usbaudiod restarts, sample rate changes by other processes).
   The sample times it then hands us restart from zero, so the read position
   derived from inputOutputSampleDelta lands minutes away from the write
   position and the output plays silence until something resets the input
   data. Detect this directly: track the sample time each IO cycle should
   start at, on both the target device side and our own, and re-anchor the
   delta on any backward jump or a large forward jump. A bounded fill level
   check on the real ring buffer capacity remains as a last resort.

2. The proxy device's synthetic clock free-ran at a rate estimated from the
   target's rate scalar, with the estimate reset on every GetZeroTimeStamp
   call (so it often fell back to nominal) and no feedback on the resulting
   position error, which accumulated until the read position ran off the
   end of the ring buffer. Now the rate scalar is only consumed when a zero
   time stamp period actually elapses, only used when the HAL flags it as
   valid, and the period length is additionally steered by a clamped PI
   correction on the mean ring buffer fill error so the two clocks stay
   phase locked. No samples are dropped or duplicated; the correction is at
   most 500 ppm. GetZeroTimeStamp also now advances by however many periods
   have elapsed rather than at most one per call.

Also fix two client-counting bugs: StartIO wiped the ring buffer for every
additional client rather than only the first, and StopIO set the final
frame time for every client rather than only the last, which would silence
the output permanently while other clients were still playing.
The ring buffer's write pointer only advances once per input cycle, so the
fill level sampled at target device cycle boundaries carries a phase
dependent offset of up to one input buffer that is not a real position
error. Once locked, the relative phase of the two cycles freezes at an
arbitrary point, the servo "corrects" that offset, the phase wraps, the
reading jumps by a buffer and the loop hunts with a limit cycle (observed
as mean fill errors of exactly -256 then +96 frames with +-250 ppm swings).

Measure the phase between the two clocks directly instead: project the
proxy's own time line (the state behind GetZeroTimeStamp) onto the host
time of each target device IO cycle and compare with the sample being
read. That quantity is continuous and independent of cycle quantisation.
@vlasky
vlasky force-pushed the fix/clock-sync-and-timeline-discontinuity branch from 17bd43c to 3bf7315 Compare August 29, 2026 10:25
@briankendall

Copy link
Copy Markdown
Owner

I've only given this code a quick glance over, but so far it looks good! When I was last working on this issue I figured out one of the two causes you've identified but never got as far as fixing the synthetic clock, but it looks like you got it. I'll see about putting out a beta version to see if this successfully addresses the issue for other uses without introducing any regressions, and I'm optimistic that it will.

@vlasky

vlasky commented Sep 10, 2026

Copy link
Copy Markdown
Author

12-day report from the machine this branch was developed on (M-series MacBook Pro, macOS 15, Dell U2723QE over DisplayPort as the proxied device, Debug build of 3bf7315 installed 29 Aug):

The original symptom has not occurred once. Previously I had to switch output devices and back several times a day to restore audio, most reliably after waking from sleep.

The timeline-discontinuity re-anchor is firing routinely and recovering every time. Five events today alone, e.g.:

11:29:32.984 ProxyAudio: target device time line discontinuity of -11190996480 frames (sample time now 13720), re-anchoring
11:29:32.984 ProxyAudio: anchored inputOutputSampleDelta at 12497529220, fill level 1344 frames, phase 1742.4 frames
13:07:07.170 ProxyAudio: target device time line discontinuity of -280991748 frames (sample time now 13716), re-anchoring
15:13:45.248 ProxyAudio: target device time line discontinuity of -364698056 frames (sample time now 13772), re-anchoring
15:26:31.194 ProxyAudio: target device time line discontinuity of -36749332 frames (sample time now 13752), re-anchoring
16:39:49.366 ProxyAudio: target device time line discontinuity of -211096604 frames (sample time now 13724), re-anchoring

Each is the DisplayPort device's engine being restarted in place on display wake, its sample time snapping back to ~13,700 after hours of running. Under 1.0.7 each of these would have been a "silent until I toggle devices" incident, which matches the several-times-a-day rate I used to see. Audio continues through every one.

Clock servo statistics (from the Debug-only status line, 5291 samples over the last 36 h): mean |correction| 0.05 ppm, max 1.2 ppm, mean phase error within ±0.2 frames, worst single reading 1.2 frames. My Dell's clock is only ~3 ppm off nominal (target rate ratio 0.999997-0.999998), so the servo barely has to work here; the interesting test of the drift fix would be a device further off nominal.

Guard rails never needed: zero out of range (last-resort fill check) and zero unexpected overrun lines in the retained log. coreaudiod has been up for 12 days straight.

Happy to keep running it and report anything unusual. Thanks for turning it into a beta so quickly.

@vlasky

vlasky commented Sep 10, 2026

Copy link
Copy Markdown
Author

Update: I've switched from my local Debug build to the official signed v1.1.0b1 beta (verified byte-identical to this branch's driver code). Confirmed working after install: proxy and target IO both running, audio routing normally. I'll report here if anything unusual shows up in the logs.

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.

Crash after several hours

2 participants