2828from music_assistant .helpers .api import api_command
2929from music_assistant .helpers .datetime import local_clock_time_to_utc
3030from music_assistant .helpers .json import json_dumps , json_loads
31+ from music_assistant .helpers .util import is_arm
3132from music_assistant .models .audio_analysis import AudioAnalysisData
3233from music_assistant .models .audio_analysis_provider import AudioAnalysisProvider
3334from music_assistant .models .music_provider import MusicProvider
@@ -136,7 +137,10 @@ def __init__(self, streams: StreamsController) -> None:
136137 self .logger = self .mass .logger .getChild ("audio_analysis" )
137138 self ._active_sessions : dict [str , set [str ]] = {}
138139 self ._workers : dict [str , asyncio .Task [None ]] = {}
139- self ._thread_caps_configured = False
140+ self ._inference_runtime_configured = False
141+ # Kept alive to persist the process-wide native BLAS thread cap (set in
142+ # ensure_inference_runtime_configured); never used as a context manager.
143+ self ._blas_limiter : object | None = None
140144
141145 def setup (self ) -> None :
142146 """Register the nightly background scan task."""
@@ -162,32 +166,49 @@ async def close(self) -> None:
162166 if workers :
163167 await asyncio .gather (* workers , return_exceptions = True )
164168
165- def ensure_thread_caps_configured (self ) -> None :
169+ def ensure_inference_runtime_configured (self ) -> None :
166170 """
167- Cap PyTorch threading for analysis inference (process-wide, applied once).
171+ Configure the on-device inference runtime for analysis (process-wide, applied once).
168172
169173 Torch-backed analysis providers call this at the start of their handle_async_init,
170174 before loading their models.
171175 """
172- # Lazy torch import: only torch-backed providers call this, so a host running no
173- # such provider never imports torch. Running before the first model load also lets
174- # set_num_interop_threads take effect (it can only be set before the first torch op).
175- if self ._thread_caps_configured :
176+ if self ._inference_runtime_configured :
176177 return
178+ # Lazy imports: only torch-backed providers call this, so a host running no such
179+ # provider never imports torch/threadpoolctl. Running before the first model load
180+ # also lets set_num_interop_threads take effect (only settable before the first op).
181+ import threadpoolctl # noqa: PLC0415
177182 import torch # noqa: PLC0415
178183
179184 budget = self ._aa_thread_budget ()
180185 torch .set_num_threads (budget )
181186 with contextlib .suppress (RuntimeError ):
182187 # set_num_interop_threads can only be called before the first torch op
183188 torch .set_num_interop_threads (1 )
189+ # torch.set_num_threads only governs torch's own ops. The per-block librosa/numpy
190+ # feature extraction runs through the native BLAS pool (OpenBLAS), which otherwise
191+ # spawns a thread per core per worker and, across concurrent sessions, saturates
192+ # every core and starves playback. Cap it to the same budget; the limiter is kept
193+ # alive on the controller so the cap persists for the process.
194+ self ._blas_limiter = threadpoolctl .threadpool_limits (limits = budget , user_api = "blas" )
195+ arm = is_arm ()
196+ if arm :
197+ # NNPACK frequently fails to initialize on ARM SBCs (e.g. Raspberry Pi); torch
198+ # then re-logs "Could not initialize NNPACK" to stderr on every conv op. The fp32
199+ # conv fallback is used on those hosts regardless, so disabling it only removes
200+ # the log spam.
201+ with contextlib .suppress (Exception ):
202+ torch .backends .nnpack .set_flags (False ) # type: ignore[no-untyped-call]
184203 self .logger .info (
185- "AudioAnalysis thread caps : torch intra=%d, torch interop=%d" ,
204+ "AudioAnalysis runtime : torch intra=%d interop=%d, blas<=%d, nnpack=%s " ,
186205 torch .get_num_threads (),
187206 torch .get_num_interop_threads (),
207+ budget ,
208+ "off" if arm else "on" ,
188209 )
189210 # Only mark done once configuration actually succeeded, so a failure retries.
190- self ._thread_caps_configured = True
211+ self ._inference_runtime_configured = True
191212
192213 @property
193214 def providers (self ) -> list [AudioAnalysisProvider ]:
0 commit comments