Skip to content

ao_avfoundation: stop requesting media while paused - #18390

Open
aliceisjustplaying wants to merge 3 commits into
mpv-player:masterfrom
aliceisjustplaying:agent/avfoundation-pause-cpu
Open

aliceisjustplaying wants to merge 3 commits into
mpv-player:masterfrom
aliceisjustplaying:agent/avfoundation-pause-cpu

Conversation

@aliceisjustplaying

@aliceisjustplaying aliceisjustplaying commented Aug 19, 2026 •

Copy link
Copy Markdown

Device changes while paused

Includes GeiserX's follow-up patch, which low-batt also verified with AirPods. The restart-notification handler now keeps the renderer stopped when playback is paused and resets the enqueue timestamp for resume, instead of unconditionally calling start().

The pause/notification race is addressed in 5582af753d by serializing start, stop, pause and notification recovery on the renderer queue. Recovery is asynchronous, and teardown invalidates the observer's AO pointer on that queue before freeing the AO. A queue-confined active flag prevents deferred recovery from restarting media requests after pause, reset or EOF.

Full macOS build and git diff --check passed. An isolated harness using real AVFoundation reproduced the pause race on the previous PR commit; the updated implementation passed that case plus same-queue notifications, pause/resume, reset, EOF and teardown checks. Reset/EOF regressions also failed an intermediate implementation and passed the final fix. AddressSanitizer covered the changed AO and harness, not all dependencies. Notifications were synthetic; actual device switching, iOS/tvOS and seamless gapless transitions were not verified.

Local verification for this update: meson setup build -Dlibmpv=true -Dcplayer=true, meson compile -C build and git diff --check passed. The device-switch runtime test was not repeated locally; the runtime results linked above belong to GeiserX and low-batt. The original verification below predates this update.

AI disclosure for these updates: GPT-6 Astra in Pi and GPT-6.1 Sol in Codex assisted with implementation, verification, review and this description.

Summary

Stop AVSampleBufferAudioRenderer from requesting media data while mpv is paused, then register the request callback again when playback resumes.

Root cause

set_pause set the render synchronizer rate to zero but left the media-data request active. If the renderer had no queued audio, ao_read_data returned no samples while paused. AVFoundation still considered the renderer ready for more data and immediately invoked the callback again, creating a busy loop on the avfoundation event queue.

The callback request now stops before the rate changes to zero. Resuming sets the rate back to one and registers a new callback, keeping the request and stop lifecycle balanced.

Observed failure

A paused mpv 0.41.0 process consistently consumed one full CPU core while its playback, decoder, demuxer, video-output, and Lua threads slept. A process sample showed the active thread in this stack:

DispatchQueue: avfoundation event
AVMediaDataRequester::_requestMediaDataIfReady
__start_block_invoke (mpv)

The process-level signal stayed consistent across repeated measurements:

cpu=98.8% state=R
cpu=100.0% state=R
cpu=99.1% state=R

Verification

  • meson compile -C build
  • git diff --check origin/master...HEAD
  • Played the same remote E-AC-3 stream with ao=avfoundation, completed five pause/resume cycles, and left the player paused
  • Repeated the pause/resume check with a generated 48 kHz audio source
  • Sampled the patched process while paused:
pause_property=true
paused_cpu=0.2
hot_callback=no

Additional macOS 26 runtime verification:

  • Built and ran the exact unpatched parent (e7191f2a65) and PR commit (4df03a9b22) on macOS 26.6.2 (25G83), arm64
  • Served a locally generated 48 kHz 5.1 E-AC-3 stream over HTTP, stopped the producer to reproduce a stalled remote stream, then paused mpv
  • Unpatched: paused_cpu=77.4; a three-second sample recorded 2,266 samples on DispatchQueue: avfoundation event, including 1,610 in -[AVMediaDataRequester _requestMediaDataIfReady]
  • Patched: paused_cpu=0.1; requestMediaDataIfReady was absent from the sample; resume and re-pause measured 0.0% CPU

This build configuration defines no automated tests. The behavior depends on the macOS AVFoundation runtime, so the verification exercises the real audio output.

AI/LLM disclosure

OpenAI Codex with GPT-5.6 Sol (high) assisted with diagnosis, implementation, testing, and drafting this pull request. I understand the change and take full responsibility for it, including its licensing under the file's existing LGPLv2.1+ terms. I will participate in review with human-written responses.

@aliceisjustplaying
aliceisjustplaying marked this pull request as ready for review August 19, 2026 13:54
@llyyr

llyyr commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

AVFoundation still considered the renderer ready for more data and immediately invoked the callback again

Why should it do that?
https://developer.apple.com/documentation/avfoundation/avsamplebufferrendersynchronizer/setrate(_:time:) says:

A rate value of 0.0 means that playback has stopped

Busylooping any time the player is paused would've resulted in far more noise about it, considering we haven't heard about this issue at all yet, is this a MacOS regression?

Comment thread audio/out/ao_avfoundation.m Outdated
struct priv *p = ao->priv;

if (paused) {
[p->renderer stopRequestingMediaData];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we use this property elsewhere in the AO too, but this is deprecated https://developer.apple.com/documentation/avfoundation/avsamplebufferaudiorenderer/stoprequestingmediadata()

It can't be the case that avoiding busylooping necessitates the use of a deprecated property

@aliceisjustplaying

Copy link
Copy Markdown
Author

I also find it strange that there is no noise about it, but I tested this both on the macOS 27 beta but also on macOS 26 now and I can reproduce it, Updated the PR description. Looking into the deprecated property.

@aliceisjustplaying

Copy link
Copy Markdown
Author

@llyyr it's deprecated for swift but not objc, and as far as i can tell apple's website is just simply wrong about this

Xcode 27 SDK, AVQueuedSampleBufferRendering.h:

#if defined(__swift__)
API_DEPRECATED("Cancel the receiver's Task instead", macos(10.13, 27.0), ...)
#else
API_AVAILABLE(macos(10.13), ...)
#endif
  • Objective-C compiled with -Werror=deprecated-declarations: exit 0.
  • The identical Swift call with warnings as errors fails as deprecated.
  • Apple’s DocC data sets the Objective-C variant to "deprecated": false and clears deprecationSummary.
  • The SDK requires every requestMediaDataWhenReadyOnQueue call to be paired with stopRequestingMediaData; otherwise behavior is undefined.

@kasper93 kasper93 added this to the Release v0.42.0 milestone Aug 23, 2026
The AVSampleBufferAudioRenderer callback remained registered when the
synchronizer rate was set to zero. If its queue was empty when playback
was paused, AVFoundation immediately invoked the callback again after
ao_read_data returned no samples, consuming a full CPU core.

Stop requesting media data before pausing and register the callback again
when playback resumes. This balances the request/stop lifecycle and
prevents the empty renderer from busy-looping.
@aliceisjustplaying
aliceisjustplaying force-pushed the agent/avfoundation-pause-cpu branch from 4df03a9 to 4e616c3 Compare September 11, 2026 18:41
@GeiserX

GeiserX commented Sep 25, 2026

Copy link
Copy Markdown

I can reproduce this on macOS 27.0 arm64 with libmpv 0.41.0, and this PR fixes it.

I hit it through Supersonic, a Subsonic client that bundles libmpv. It sat at 91-100% CPU while paused, and a sample of the process showed the same stack: avfoundation event → -[AVMediaDataRequester _requestMediaDataIfReady] → __start_block_invoke → feed().

My reproducer uses libmpv with ao=avfoundation, video=no and mute=yes. A local HTTP server sends the first 6 s of a 48 kHz s16 WAV, then keeps the connection open and sends nothing more. Play it, pause at 10 s when the renderer queue has drained, and measure process CPU over the next 6 s.

libmpv paused CPU, two runs
v0.41.0 + c5d391a 99.5%, 99.4%
same + this PR 0.1%, 0.0%

Pausing with audio still queued doesn't trigger it on either build. Neither does starting paused, seeking while paused, adding and removing an aggregate device while paused, or ao-reload while paused. It takes the empty queue, which matches the root cause in the description. With the patch, three pause/resume cycles keep time-pos right. It holds while paused and advances on resume.

The harness is about 60 lines of C plus a small Python server. Happy to share it.

Disclosure: I used an AI coding assistant for the diagnosis and the test harness. I ran the tests and checked the results myself.

@aliceisjustplaying

Copy link
Copy Markdown
Author

@GeiserX oh thank god it's not just my computer

@low-batt

low-batt commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

I have built mpv 0.41.0 patched with this PR and the problem still occurs for me.

IINA has upgraded from mvp 0.38.0 to mpv 0.41.0 and what I believe is this problem has been reported in IINA issue iina/iina#6445. The problem is not reproducible with the older version of IINA that uses mpv 0.38.0, so this is a regression.

To test the fix I'm using mpv-build to build mpv on my M1 running macOS 15.7.1 Sequoia with Xcode 26.3. I patched mpv 0.41.0 with this PR:

git diff:
low-batt@gag mpv ((v0.41.0) *%)$ git diff
diff --git a/audio/out/ao_avfoundation.m b/audio/out/ao_avfoundation.m
index 09f3ef002a..fbb1ed8c30 100644
--- a/audio/out/ao_avfoundation.m
+++ b/audio/out/ao_avfoundation.m
@@ -140,17 +140,24 @@ static void feed(struct ao *ao)
     if (sample_buffer) CFRelease(sample_buffer);
 }
 
-static void start(struct ao *ao)
+static void request_media_data(struct ao *ao)
 {
     struct priv *p = ao->priv;
 
-    p->end_time_av = -1;
-    [p->synchronizer setRate:1];
     [p->renderer requestMediaDataWhenReadyOnQueue:p->queue usingBlock:^{
         feed(ao);
     }];
 }
 
+static void start(struct ao *ao)
+{
+    struct priv *p = ao->priv;
+
+    p->end_time_av = -1;
+    [p->synchronizer setRate:1];
+    request_media_data(ao);
+}
+
 static void stop(struct ao *ao)
 {
     struct priv *p = ao->priv;
@@ -167,9 +174,11 @@ static bool set_pause(struct ao *ao, bool paused)
     struct priv *p = ao->priv;
 
     if (paused) {
+        [p->renderer stopRequestingMediaData];
         [p->synchronizer setRate:0];
     } else {
         [p->synchronizer setRate:1];
+        request_media_data(ao);
     }
 
     return true;
low-batt@gag mpv ((v0.41.0) *%)$ 

I then did a full clean build of mpv:

mpv --version:
low-batt@gag mpv-build (master %=)$ mpv/build/mpv --version
mpv v0.41.0-dirty Copyright © 2000-2025 mpv/MPlayer/mplayer2 projects
 built on Oct  4 2026 17:46:51
libplacebo version: v7.360.0 (v7.360.0)
FFmpeg version: n9.0.2
FFmpeg library versions:
   libavcodec      63.1.102
   libavdevice     63.1.102
   libavfilter     12.1.102
   libavformat     63.1.102
   libavutil       61.1.102
   libswresample   7.1.102
   libswscale      10.1.102

I started mpv playing a video with these options:

mpv/build/mpv --no-config --gpu-debug --log-file=output.txt --ao=avfoundation --autofit=80% ~/Movies/Big\ Buck\ Bunny\ 60fps\ 4K\ -\ Official\ Blender\ Foundation\ Short\ Film\ \[aqz-KE-bpKQ\].mp4

I paused playback. Then in another terminal window I starting monitoring mpv's CPU usage by running this command:

while true; do ps -c -o 'comm,%cpu,cputime' | grep mpv | grep -v grep; sleep 5; done

I then connected my AirPods Max.

The monitoring of the CPU usage shows:

  • mpv initially nearly idle while paused
  • CPU usage jumps to 100% once AirPods Max connected
  • CPU usage goes to %217 when I started playback again
  • After pausing playback again mpv goes back to being nearly idle
low-batt@gag ~$ while true; do ps -c -o 'comm,%cpu,cputime' | grep mpv | grep -v grep; sleep 5; done
mpv     0.4   0:10.04
mpv     0.3   0:10.08
mpv     0.4   0:10.11
mpv     0.4   0:10.13
mpv    99.4   0:13.98
mpv   100.0   0:19.03
mpv   100.0   0:24.06
mpv   100.0   0:29.10
mpv     94.7   0:33.82
mpv     94.8   0:38.59
mpv    99.3   0:43.63
mpv   217.7   0:53.74
mpv     0.4   0:56.33
mpv    13.5   0:56.51
mpv     0.3   0:56.81
mpv     0.3   0:56.84
^C

The terminal running mpv shows audio restarted due to a system notification:

Terminal output:
low-batt@gag mpv-build (master %=)$ mpv/build/mpv --no-config --gpu-debug --log-file=output.txt --ao=avfoundation --autofit=80% ~/Movies/Big\ Buck\ Bunny\ 60fps\ 4K\ -\ Official\ Blender\ Foundation\ Short\ Film\ \[aqz-KE-bpKQ\].mp4
● Video  --vid=1  (av1 3840x2160 60 fps) [default]
● Audio  --aid=1  (aac 6ch 48000 Hz 386 kbps) [default]
AO: [avfoundation] 48000Hz 5.1 6ch float
VO: [gpu-next] 3840x2160 yuv420p
[ao/avfoundation] restarting due to system notification; this will cause desync
                                                  
Audio/Video desynchronisation detected! Possible reasons include too slow
hardware, temporary CPU spikes, broken drivers, and broken files. Audio
position will not match to the video (see A-V status field).
Consider trying `--profile=fast` and/or `--hwdec=auto` as they may help.

(Paused) AV: 00:00:11 / 00:10:34 (2%) A-V:  0.000 Dropped: 96
Exiting... (Quit)
low-batt@gag mpv-build (master %=)$ 

I ran sample mpv in another terminal window while mpv was consuming 100% CPU. The process sample shows it is the avfoundation event thread that is active:

Clip from Process Sample:
low-batt@gag ~$ sample mpv
Sampling process 33203 for 10 seconds with 1 millisecond of run time between samples
Sampling completed, processing symbols...
Sample analysis of process 33203 written to file /tmp/mpv_2026-10-04_175602_aAbQ.sample.txt

Analysis of sampling mpv (pid 33203) every 1 millisecond
Process:         mpv [33203]
Path:            /Users/USER/Documents/*/mpv
Load Address:    0x1024bc000
Identifier:      mpv
Version:         0
Code Type:       ARM64
Platform:        macOS
Parent Process:  bash [59180]
Target Type:     live task

Date/Time:       2026-10-04 17:56:02.836 -0400
Launch Time:     2026-10-04 17:55:15.946 -0400
OS Version:      macOS 15.7.9 (24G830)
Report Version:  7
Analysis Tool:   /usr/bin/sample

Physical footprint:         751.2M
Physical footprint (peak):  796.8M
Idle exit:                  untracked
----

Call graph:
…
    8249 Thread_21817160   DispatchQueue_451: avfoundation event  (serial)
    + 8249 start_wqthread  (in libsystem_pthread.dylib) + 8  [0x19fe42b74]
    +   8249 _pthread_wqthread  (in libsystem_pthread.dylib) + 292  [0x19fe43e20]
    +     8249 _dispatch_workloop_worker_thread  (in libdispatch.dylib) + 540  [0x19fca2ae8]
    +       8249 _dispatch_root_queue_drain_deferred_wlh  (in libdispatch.dylib) + 292  [0x19fca3264]
    +         8249 _dispatch_lane_invoke  (in libdispatch.dylib) + 388  [0x19fc98e2c]
    +           8168 _dispatch_lane_serial_drain  (in libdispatch.dylib) + 740  [0x19fc98350]
    +           ! 8152 _dispatch_client_callout  (in libdispatch.dylib) + 16  [0x19fca985c]
    +           ! : 7481 _dispatch_call_block_and_release  (in libdispatch.dylib) + 32  [0x19fc8fb2c]
    +           ! : | 5919 -[AVMediaDataRequester _requestMediaDataIfReady]  (in AVFCore) + 80  [0x1b77043e0]
    +           ! : | + 5892 __request_media_data_block_invoke  (in mpv) + 36  [0x1026eb9f0]  ao_avfoundation.m:148
    +           ! : | + ! 4329 feed  (in mpv) + 168  [0x1026ebaa4]  ao_avfoundation.m:75
    +           ! : | + ! : 3809 -[AVSampleBufferRenderSynchronizer currentTime]  (in AVFCore) + 48  [0x1b77089fc]
    +           ! : | + ! : | 1587 figTimebaseGetTime_MaybeUpdatingAnchorTimeFromLoopiness  (in CoreMedia) + 128  [0x1ab4edaa0]
…

Given the report from @GeiserX that the fix is working, I am straining my brain trying to figure out what I am doing wrong. Feels like somehow I'm not testing with the fix. Too tired to think at this point.

If someone can approve the workflows for this PR to run I will test the mpv created by the CI build.

@GeiserX

GeiserX commented Oct 5, 2026

Copy link
Copy Markdown

@low-batt your test is right. The PR does not cover a device change while paused, and I reproduced your result.

My earlier test paused with an empty renderer queue and measured CPU. It never changed the output device while paused, so it never went through handleRestartNotification. That handler runs stop(ao); start(ao); without looking at the pause state. start() sets the synchronizer rate back to 1 and calls requestMediaDataWhenReadyOnQueue again. The core is still paused, ao_read_data returns 0 samples, and the callback spins. set_pause(true) from this PR already ran before the device change, so it cannot help. On resume, set_pause(false) registers a second request block on top of the one from the restart, which matches the 217% you saw before pausing again.

Reproduced on macOS 27.0.1 arm64 with libmpv built from v0.41.0 plus this PR, ao=avfoundation, a silent 48 kHz file, mute=yes, volume=0. The test program pauses, switches the system default output device to the built-in speakers, switches it back, and measures process CPU in each phase. The notification was AVSampleBufferAudioRendererWasFlushedAutomaticallyNotification in every run. Two runs per build:

phase v0.41.0 + this PR + the diff below
paused, before the switch 0.5%, 0.5% 0.4%, 0.5%
paused, after the switch 98.9%, 99.9% 0.1%, 0.1%
paused, after switching back 99.5%, 99.7% 0.1%, 0.1%
resumed, time-pos advancing 1.0%, 1.1% 1.0%, 1.0%
paused again 0.4%, 0.5% 0.5%, 0.5%

Same shape as your ps loop: idle, 100% once the headphones connect, idle again after the next pause.

I also had it live. Supersonic bundles exactly this build (v0.41.0 plus this PR) and had been sitting paused at 100% CPU for 1480 CPU-minutes after a Bluetooth speaker reconnected. sample shows the avfoundation event queue in -[AVMediaDataRequester _requestMediaDataIfReady] calling __request_media_data_block_invoke.

The change I tested skips the restart while paused. set_pause(false) already starts the renderer again on resume, so nothing else is needed:

--- a/audio/out/ao_avfoundation.m
+++ b/audio/out/ao_avfoundation.m
@@ -15,6 +15,8 @@
  * License along with mpv.  If not, see <http://www.gnu.org/licenses/>.
  */
 
+#include <stdatomic.h>
+
 #include "ao.h"
 #include "audio/format.h"
 #include "audio/out/ao_coreaudio_chmap.h"
@@ -45,6 +47,7 @@ - (void)handleRestartNotification:(NSNotification*)notification;
     CMAudioFormatDescriptionRef format_description;
     AVObserver *observer;
     int64_t end_time_av;
+    atomic_bool paused;
 };
 
 static int64_t CMTimeGetNanoseconds(CMTime time)
@@ -153,6 +156,7 @@ static void start(struct ao *ao)
 {
     struct priv *p = ao->priv;
 
+    atomic_store(&p->paused, false);
     p->end_time_av = -1;
     [p->synchronizer setRate:1];
     request_media_data(ao);
@@ -173,6 +177,7 @@ static bool set_pause(struct ao *ao, bool paused)
 {
     struct priv *p = ao->priv;
 
+    atomic_store(&p->paused, paused);
     if (paused) {
         [p->renderer stopRequestingMediaData];
         [p->synchronizer setRate:0];
@@ -219,8 +224,16 @@ - (void)handleRestartNotification:(NSNotification*)notification {
     MP_WARN(ao, "restarting due to system notification; this will cause desync\n");
     MP_VERBOSE(ao, "notification name: %s\n", name);
     talloc_free(name);
+    struct priv *p = ao->priv;
     stop(ao);
-    start(ao);
+    if (atomic_load(&p->paused)) {
+        // Keep the renderer stopped; set_pause(false) requests media data
+        // again. The flushed buffers are gone, so enqueue from the current
+        // time on resume.
+        p->end_time_av = -1;
+    } else {
+        start(ao);
+    }
 }
 @end

One honest gap: a notification that lands in the middle of set_pause() can still race it. Serializing both on p->queue would close that, but stop() already does a dispatch_sync on that queue, so it needs a small restructure. I left it out of this diff.

On why 0.38 did not have this: a3a9bc2 removed the mp_sleep_ns(10000000) that feed() used when it read 0 samples. The blocking ao_read_data only blocks on the buffer mutex, so any path that leaves the renderer requesting data with nothing to read now spins instead of ticking every 10 ms.
a3a9bc289a

@aliceisjustplaying fold this into the PR if you want, or I can open a follow-up PR, whichever is easier for you. The test program is about 250 lines of C that dlopens libmpv and keeps everything muted:
https://gist.github.com/GeiserX/6f9081751dc3ca4a8b1a380a2c897592

Disclosure: I used an AI coding assistant for the diagnosis and the test program. I built both libraries and ran the tests myself.

@low-batt

low-batt commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

@GeiserX Excellent analysis!

I built mpv 0.41.0 with the patch in your comment and repeated the test I have been running. When I connected my AirPods the CPU usage did not change.

The revised fix works for me!

@low-batt

low-batt commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

@aliceisjustplaying I'm expecting the mpv developers would prefer one PR with a more comprehensive fix. Please do consider folding the patch in this comment from @GeiserX into this PR.

Fold in the follow-up patch from GeiserX. A device-change notification
previously called start() unconditionally, re-enabling media requests
while the core was still paused. Keep the renderer stopped in that case
and reset the enqueue timestamp for resume.

This does not serialize notifications against set_pause(); the race
noted with the posted patch remains.

Patch-by: GeiserX
Link: mpv-player#18390 (comment)
@aliceisjustplaying

Copy link
Copy Markdown
Author

Folded in the device-change patch. Thanks @GeiserX and @low-batt!

Run start, stop, pause and notification recovery on the renderer queue
so a restart cannot overtake a pause. Track whether media requests are
active, including reset and EOF, rather than only tracking pause.

Enqueue notification recovery asynchronously to avoid a synchronous
wait on the posting thread or a nested dispatch_sync on the renderer
queue. Invalidate the observer AO pointer on the queue during teardown;
queued recovery retains the observer and ignores an invalidated AO.
@low-batt

Copy link
Copy Markdown
Contributor

I have installed macOS 27.0.1 and Xcode 27.0 on my M1, built mpv from the current master:

mpv --version:
low-batt@gag mpv-build (master %=)$ mpv/build/mpv --version
mpv v0.41.0-1121-g57c745b1a-dirty Copyright © 2000-2026 mpv/MPlayer/mplayer2 projects
 built on Oct 10 2026 09:05:51
libplacebo version: v7.374.0 (v7.360.0-152-g0d043c7f)
FFmpeg version: n9.0.2
FFmpeg library versions:
   libavcodec      63.1.102
   libavdevice     63.1.102
   libavfilter     12.1.102
   libavformat     63.1.102
   libavutil       61.1.102
   libswresample   7.1.102
   libswscale      10.1.102

And confirmed the problem still reproduces with master.

I then patched mpv with the latest commits from this PR. Rebuilt mpv and confirmed the test I've been running passes. The changes are working for me.

@aliceisjustplaying @GeiserX Thank you very much for your excellent work on this problem.

@Akemi

Akemi commented Oct 25, 2026

Copy link
Copy Markdown
Member

can you look over this @ruihe774 if you have some time, pls?

@Akemi

Akemi commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

not sure if a sync queue is the right approach here or we should rather use a mutex lock instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants