Skip to content

Commit 1aa86f6

Browse files
mm-elmazaniclaude
andcommitted
[PERF] MediaWatcher passe en evenementiel WinRT au lieu du sondage continu
Le pic CPU observe en live (NPSMSvc + MusicOverlayServer.exe a 85-89%) venait de la boucle interne qui interrogeait WinRT (proprietes, lecture, timeline) toutes les 0.5s en continu, meme sans que rien ne change. MediaWatcher s'abonne maintenant aux evenements de la session (MediaPropertiesChanged, PlaybackInfoChanged, TimelinePropertiesChanged, CurrentSessionChanged) : le cout WinRT n'est plus paye qu'au changement reel. Un filet de securite espace (~5x refresh_interval) rattrape les lecteurs qui n'emettent pas ces evenements de facon fiable. La position de lecture est interpolee localement (ancre position + temps ecoule + vitesse) pour rester fluide sans repoller WinRT a chaque affichage. Valide en conditions reelles sur une session Apple Music : ~10x moins de charge CPU sur NPSMSvc (2,1% -> 0,2% de moyenne sur 15s de lecture), position/titre/pochette corrects, abonnement/desabonnement propre au changement de session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 399cbea commit 1aa86f6

1 file changed

Lines changed: 230 additions & 47 deletions

File tree

music_overlay/media.py

Lines changed: 230 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
import base64
1313
import logging
1414
import threading
15+
import time
1516
from collections.abc import Callable
16-
from dataclasses import asdict, dataclass
17+
from dataclasses import asdict, dataclass, replace
1718
from typing import Any
1819

1920
from .config import ConfigStore, MediaFilter
@@ -114,10 +115,17 @@ def _seconds(value: Any) -> int:
114115

115116

116117
class MediaWatcher:
117-
"""Interroge Windows en boucle et publie la piste courante.
118+
"""Publie la piste courante à partir des évènements WinRT de la session média.
118119
119-
Le thread est démarré/arrêté avec le serveur. ``current`` reste lisible à
120-
tout moment depuis n'importe quel thread.
120+
Le thread est démarré/arrêté avec le serveur et fait tourner une boucle
121+
asyncio dédiée. Au lieu de sonder Windows en continu, on s'abonne aux
122+
évènements de la session (``MediaPropertiesChanged``, etc.) : le coût
123+
WinRT/NPSMSvc n'est payé que quand quelque chose change réellement, plus
124+
jamais à un rythme fixe. ``refresh_interval`` ne sert plus que de filet de
125+
sécurité (certains lecteurs ne déclenchent pas ces évènements de façon
126+
fiable) et de base au recalcul de la position pendant la lecture.
127+
128+
``current`` reste lisible à tout moment depuis n'importe quel thread.
121129
"""
122130

123131
def __init__(self, config: ConfigStore, on_error: Callable[[Exception], None] | None = None):
@@ -130,13 +138,36 @@ def __init__(self, config: ConfigStore, on_error: Callable[[Exception], None] |
130138
self._thread: threading.Thread | None = None
131139
self._last_error: str | None = None
132140

141+
# Etat interne au thread media-watcher uniquement (pas de verrou requis).
142+
self._loop: asyncio.AbstractEventLoop | None = None
143+
self._manager: Any = None
144+
self._session: Any = None
145+
self._session_tokens: list[Any] = []
146+
self._session_changed_token: Any = None
147+
self._background_tasks: set[asyncio.Task[None]] = set()
148+
149+
# Anchre pour interpoler la position de lecture sans repoller WinRT :
150+
# position = _position_anchor + ecoule_depuis_anchor * _playback_rate.
151+
self._position_anchor = 0
152+
self._position_anchor_time = 0.0
153+
self._playback_rate = 0.0
154+
self._duration = 0
155+
133156
# ------------------------------------------------------------------
134157
# État
135158
# ------------------------------------------------------------------
136159
@property
137160
def current(self) -> Track:
138161
with self._lock:
139-
return self._track
162+
track = self._track
163+
if self._playback_rate == 0.0:
164+
return track
165+
elapsed = time.monotonic() - self._position_anchor_time
166+
position = int(self._position_anchor + elapsed * self._playback_rate)
167+
position = max(0, min(self._duration, position))
168+
if position == track.position:
169+
return track
170+
return replace(track, position=position)
140171

141172
@property
142173
def running(self) -> bool:
@@ -162,12 +193,16 @@ def start(self) -> None:
162193
def stop(self, timeout: float = 3.0) -> None:
163194
"""Demande l'arrêt du thread et attend sa fin."""
164195
self._stop_event.set()
196+
loop = self._loop
197+
if loop is not None and loop.is_running():
198+
loop.call_soon_threadsafe(loop.stop)
165199
thread = self._thread
166200
if thread is not None and thread.is_alive():
167201
thread.join(timeout=timeout)
168202
self._thread = None
169203
with self._lock:
170204
self._track = NO_TRACK
205+
self._playback_rate = 0.0
171206
logger.info("Surveillance media arretee")
172207

173208
# ------------------------------------------------------------------
@@ -183,63 +218,211 @@ def _run(self) -> None:
183218

184219
loop = asyncio.new_event_loop()
185220
asyncio.set_event_loop(loop)
221+
self._loop = loop
186222
try:
187223
while not self._stop_event.is_set():
188224
try:
189-
track = loop.run_until_complete(self._poll_once())
190-
with self._lock:
191-
self._track = track or NO_TRACK
192-
self._last_error = None
225+
loop.run_until_complete(self._setup())
226+
loop.run_forever()
193227
except Exception as exc:
194228
with self._lock:
195229
self._last_error = str(exc)
196-
logger.debug("Lecture media echouee : %s", exc)
230+
logger.debug("Surveillance media interrompue : %s", exc)
197231
if self._on_error is not None:
198232
self._on_error(exc)
199-
200-
self._stop_event.wait(self._config.settings.refresh_interval)
233+
self._detach_session()
234+
# `_setup()` a echoue avant tout abonnement (WinRT
235+
# temporairement indisponible) : on retente au lieu de
236+
# laisser le thread mourir definitivement.
237+
self._stop_event.wait(self._config.settings.refresh_interval)
201238
finally:
239+
self._detach_session()
240+
if self._manager is not None and self._session_changed_token is not None:
241+
try:
242+
self._manager.remove_current_session_changed(self._session_changed_token)
243+
except Exception as exc:
244+
logger.debug("Desabonnement manager echoue : %s", exc)
245+
self._manager = None
246+
self._session_changed_token = None
247+
self._loop = None
202248
loop.close()
203249

204-
async def _poll_once(self) -> Track | None:
205-
manager = await MediaManager.request_async()
206-
session = manager.get_current_session()
207-
if session is None:
208-
return None
250+
async def _setup(self) -> None:
251+
self._manager = await MediaManager.request_async()
252+
self._session_changed_token = self._manager.add_current_session_changed(
253+
self._on_current_session_changed
254+
)
255+
await self._attach_session(self._manager.get_current_session())
256+
self._schedule_safety_tick()
257+
258+
def _spawn(self, coro: Any) -> None:
259+
"""Lance une coroutine en tâche de fond sans perdre sa référence.
260+
261+
``asyncio`` ne garantit pas qu'une tâche créée sans réference reste en
262+
vie jusqu'à son terme (RUF006) : on la garde dans un set le temps
263+
qu'elle s'exécute.
264+
"""
265+
task = self._loop.create_task(coro)
266+
self._background_tasks.add(task)
267+
task.add_done_callback(self._background_tasks.discard)
268+
269+
# Multiplicateur appliqué a ``refresh_interval`` pour l'intervalle du
270+
# filet de securite. Les evenements WinRT font le travail reactif ; ce
271+
# tick ne sert qu'a rattraper un evenement manque ou un lecteur qui n'en
272+
# emet pas. Le decoupler ainsi de ``refresh_interval`` evite de retomber
273+
# dans un sondage continu au meme rythme que l'ancienne boucle.
274+
_SAFETY_TICK_FACTOR = 5.0
275+
_SAFETY_TICK_MIN = 2.0
276+
277+
def _schedule_safety_tick(self) -> None:
278+
if self._stop_event.is_set() or self._loop is None:
279+
return
280+
delay = max(
281+
self._SAFETY_TICK_MIN, self._config.settings.refresh_interval * self._SAFETY_TICK_FACTOR
282+
)
283+
self._loop.call_later(delay, self._safety_tick)
209284

210-
app_id = session.source_app_user_model_id or ""
211-
media_filter: MediaFilter = self._config.media_filter
212-
if not media_filter.allows(app_id):
213-
logger.debug("Application filtree : %s", app_id)
214-
return None
215-
216-
properties = await session.try_get_media_properties_async()
217-
playback = session.get_playback_info()
218-
timeline = session.get_timeline_properties()
219-
220-
title = getattr(properties, "title", "") or "Unknown Title"
221-
artist = getattr(properties, "artist", "") or "Unknown Artist"
222-
album = getattr(properties, "album_title", "") or ""
223-
key = (title, artist, album)
224-
225-
cached_key, cached_thumbnail = self._thumbnail_cache
226-
if cached_key == key:
227-
thumbnail = cached_thumbnail
285+
def _safety_tick(self) -> None:
286+
"""Filet de sécurité : rattrape un évènement manqué ou un lecteur muet."""
287+
if self._stop_event.is_set() or self._loop is None:
288+
return
289+
self._spawn(self._recheck_current_session())
290+
self._schedule_safety_tick()
291+
292+
async def _recheck_current_session(self) -> None:
293+
try:
294+
current = self._manager.get_current_session()
295+
except Exception as exc:
296+
logger.debug("Relecture de la session courante echouee : %s", exc)
297+
return
298+
299+
current_app_id = getattr(current, "source_app_user_model_id", None)
300+
attached_app_id = getattr(self._session, "source_app_user_model_id", None)
301+
if current_app_id != attached_app_id:
302+
await self._attach_session(current)
228303
else:
229-
thumbnail = await _read_thumbnail(properties)
230-
self._thumbnail_cache = (key, thumbnail)
231-
232-
return Track(
233-
title=title,
234-
artist=artist,
235-
album=album,
236-
thumbnail=thumbnail,
237-
is_playing=getattr(playback, "playback_status", 0) == PLAYBACK_STATUS_PLAYING,
238-
position=_seconds(getattr(timeline, "position", None)),
239-
duration=_seconds(getattr(timeline, "end_time", None)),
240-
source_app=app_id,
304+
await self._refresh_current_session()
305+
306+
# ------------------------------------------------------------------
307+
# Session courante : abonnement et lecture
308+
# ------------------------------------------------------------------
309+
def _on_current_session_changed(self, manager: Any, args: Any) -> None:
310+
loop = self._loop
311+
if loop is None:
312+
return
313+
loop.call_soon_threadsafe(
314+
lambda: self._spawn(self._attach_session(manager.get_current_session()))
241315
)
242316

317+
def _on_session_event(self, sender: Any, args: Any) -> None:
318+
loop = self._loop
319+
if loop is None:
320+
return
321+
loop.call_soon_threadsafe(lambda: self._spawn(self._refresh_current_session()))
322+
323+
def _detach_session(self) -> None:
324+
session = self._session
325+
if session is not None:
326+
removers = (
327+
(session.remove_media_properties_changed, self._session_tokens[0])
328+
if len(self._session_tokens) > 0
329+
else None,
330+
(session.remove_playback_info_changed, self._session_tokens[1])
331+
if len(self._session_tokens) > 1
332+
else None,
333+
(session.remove_timeline_properties_changed, self._session_tokens[2])
334+
if len(self._session_tokens) > 2
335+
else None,
336+
)
337+
for entry in removers:
338+
if entry is None:
339+
continue
340+
remover, token = entry
341+
try:
342+
remover(token)
343+
except Exception as exc:
344+
logger.debug("Desabonnement session echoue : %s", exc)
345+
self._session = None
346+
self._session_tokens = []
347+
348+
async def _attach_session(self, session: Any) -> None:
349+
self._detach_session()
350+
self._session = session
351+
if session is None:
352+
with self._lock:
353+
self._track = NO_TRACK
354+
self._playback_rate = 0.0
355+
self._last_error = None
356+
return
357+
358+
self._session_tokens = [
359+
session.add_media_properties_changed(self._on_session_event),
360+
session.add_playback_info_changed(self._on_session_event),
361+
session.add_timeline_properties_changed(self._on_session_event),
362+
]
363+
await self._refresh_current_session()
364+
365+
async def _refresh_current_session(self) -> None:
366+
session = self._session
367+
if session is None:
368+
return
369+
370+
try:
371+
app_id = session.source_app_user_model_id or ""
372+
media_filter: MediaFilter = self._config.media_filter
373+
if not media_filter.allows(app_id):
374+
logger.debug("Application filtree : %s", app_id)
375+
with self._lock:
376+
self._track = NO_TRACK
377+
self._playback_rate = 0.0
378+
self._last_error = None
379+
return
380+
381+
properties = await session.try_get_media_properties_async()
382+
playback = session.get_playback_info()
383+
timeline = session.get_timeline_properties()
384+
385+
title = getattr(properties, "title", "") or "Unknown Title"
386+
artist = getattr(properties, "artist", "") or "Unknown Artist"
387+
album = getattr(properties, "album_title", "") or ""
388+
key = (title, artist, album)
389+
390+
cached_key, cached_thumbnail = self._thumbnail_cache
391+
if cached_key == key:
392+
thumbnail = cached_thumbnail
393+
else:
394+
thumbnail = await _read_thumbnail(properties)
395+
self._thumbnail_cache = (key, thumbnail)
396+
397+
is_playing = getattr(playback, "playback_status", 0) == PLAYBACK_STATUS_PLAYING
398+
position = _seconds(getattr(timeline, "position", None))
399+
duration = _seconds(getattr(timeline, "end_time", None))
400+
rate = getattr(playback, "playback_rate", None) or 1.0
401+
402+
track = Track(
403+
title=title,
404+
artist=artist,
405+
album=album,
406+
thumbnail=thumbnail,
407+
is_playing=is_playing,
408+
position=position,
409+
duration=duration,
410+
source_app=app_id,
411+
)
412+
with self._lock:
413+
self._track = track
414+
self._duration = duration
415+
self._position_anchor = position
416+
self._position_anchor_time = time.monotonic()
417+
self._playback_rate = rate if is_playing else 0.0
418+
self._last_error = None
419+
except Exception as exc:
420+
with self._lock:
421+
self._last_error = str(exc)
422+
logger.debug("Lecture media echouee : %s", exc)
423+
if self._on_error is not None:
424+
self._on_error(exc)
425+
243426

244427
def _all_sessions(manager: Any) -> list[Any]:
245428
"""Toutes les sessions média, avec repli sur la session courante.

0 commit comments

Comments
 (0)