Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3c9559e
feat(audio_analysis): add post_analysis hook to AudioAnalysisProvider…
chrisuthe Apr 30, 2026
5f85028
feat(audio_analysis): _finalize returns analysis; finalize wrapper fi…
chrisuthe Apr 30, 2026
0c8601a
fix(audio_analysis): add exc_info to post_analysis warning and assert…
chrisuthe Apr 30, 2026
fa3d2a0
docs(audio_analysis): codify provider contract in base class docstring
chrisuthe Apr 30, 2026
fcf2d2a
feat(loudness_analysis): _finalize returns persisted analysis
chrisuthe Apr 30, 2026
f11556e
feat(loudness_analysis): implement post_analysis for ReplayGain tag-w…
chrisuthe Apr 30, 2026
ecadfef
feat(smart_fades): _finalize returns persisted analysis
chrisuthe Apr 30, 2026
fdab5de
refactor(audio_analysis): extract _distribute_chunk for shared chunk …
chrisuthe Apr 30, 2026
c9d09be
feat(audio_analysis): add config and constants for background streaming
chrisuthe Apr 30, 2026
42a731f
feat(audio_analysis): add background streaming runner with per-track …
chrisuthe Apr 30, 2026
6c8c4b5
feat(audio_analysis): rewrite background scan as decode-once-fan-out …
chrisuthe Apr 30, 2026
3e8cfd9
refactor(audio_analysis): remove analyze_file from providers and dead…
chrisuthe Apr 30, 2026
d2cebfe
test(audio_analysis): update controller tests for new exception/evict…
chrisuthe Apr 30, 2026
76a96de
refactor(audio_analysis): remove analyze_file default from base class
chrisuthe Apr 30, 2026
caea261
test(audio_analysis): end-to-end integration test for streaming backg…
chrisuthe Apr 30, 2026
62861b0
fix(audio_analysis): use __getitem__ on sqlite3.Row in candidate-find…
chrisuthe Apr 30, 2026
2a37efa
fix(audio_analysis): output PCM_S16LE from ffmpeg, not source format
chrisuthe Apr 30, 2026
aaa7a71
feat(audio_analysis): configurable scan window with start/end hour an…
chrisuthe Apr 30, 2026
76388d9
Merge branch 'dev' into feat/audio-analysis-streaming-bg-scan
chrisuthe Apr 30, 2026
b2b7657
Merge branch 'dev' into feat/audio-analysis-streaming-bg-scan
chrisuthe May 4, 2026
0f27dce
refactor(audio_analysis): hand bg scan scheduling to TasksController
chrisuthe May 4, 2026
d20e46a
Merge branch 'dev' into feat/audio-analysis-streaming-bg-scan
chrisuthe May 4, 2026
dcb5c1e
chore(audio_analysis): drop orphaned WAV fixture replaced by FLAC
chrisuthe May 4, 2026
c4a7ce6
feat(audio_analysis): bound bg scan with 4h budget, clean cancel + close
chrisuthe May 4, 2026
1563e1b
style(audio_analysis): trim verbose docstrings and follow OHF format
chrisuthe May 4, 2026
1e18985
refactor(audio_analysis): address PR review (#3821) cleanups
chrisuthe May 4, 2026
52d2f8d
refactor(audio_analysis): use get_media_stream for bg scan decode
chrisuthe May 4, 2026
0cfc72d
refactor(audio_analysis): use dataclasses.replace for pcm_format, tri…
chrisuthe May 4, 2026
9158d79
PR Suggestions
chrisuthe May 4, 2026
ba64f20
refactor(audio_analysis): centralize set_audio_analysis in base final…
chrisuthe May 4, 2026
0a5d19b
chore(audio_analysis): group smart fades log-level under audio_analys…
chrisuthe May 4, 2026
74a4753
test(audio_analysis): mock set_audio_analysis as AsyncMock in base-cl…
chrisuthe May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions music_assistant/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,13 @@
CONF_PROTOCOL_KEY_SPLITTER: Final[str] = "||protocol||"
CONF_PROTOCOL_CATEGORY_PREFIX: Final[str] = "protocol"
CONF_DEFAULT_PROVIDERS_SETUP: Final[str] = "default_providers_setup"
CONF_BACKGROUND_SCAN_CONCURRENCY: Final[str] = "background_scan_concurrency"


# config default values
DEFAULT_HOST: Final[str] = "0.0.0.0"
DEFAULT_PORT: Final[int] = 8095
DEFAULT_BACKGROUND_SCAN_CONCURRENCY: Final[int] = 1


# common db tables
Expand Down
422 changes: 271 additions & 151 deletions music_assistant/controllers/streams/audio_analysis.py

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion music_assistant/controllers/streams/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

from music_assistant.constants import (
ANNOUNCE_ALERT_FILE,
CONF_BACKGROUND_SCAN_CONCURRENCY,
CONF_BIND_IP,
CONF_BIND_PORT,
CONF_CROSSFADE_DURATION,
Expand All @@ -43,6 +44,7 @@
CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS,
CONF_VOLUME_NORMALIZATION_RADIO,
CONF_VOLUME_NORMALIZATION_TRACKS,
DEFAULT_BACKGROUND_SCAN_CONCURRENCY,
DEFAULT_STREAM_HEADERS,
DLNA_CONTENT_FEATURES,
DLNA_CONTENT_FEATURES_REALTIME,
Expand Down Expand Up @@ -249,9 +251,20 @@ async def get_config_entries(
description="Log level for the Smart Fades mixer and analyzer.",
options=CONF_ENTRY_LOG_LEVEL.options,
default_value="GLOBAL",
category="generic",
category="audio_analysis",
advanced=True,
),
ConfigEntry(
key=CONF_BACKGROUND_SCAN_CONCURRENCY,
type=ConfigEntryType.INTEGER,
range=(1, 8),
default_value=DEFAULT_BACKGROUND_SCAN_CONCURRENCY,
label="Background analysis concurrency",
description="Maximum number of tracks analyzed concurrently during the nightly "
"background scan. Default 1 (serial). Increase only if your hardware can handle "
"concurrent torch/ffmpeg work.",
category="audio_analysis",
),
)

async def setup(self, config: CoreConfig) -> None:
Expand Down Expand Up @@ -313,6 +326,7 @@ async def setup(self, config: CoreConfig) -> None:

async def close(self) -> None:
"""Cleanup on exit."""
await self._audio_analysis.close()
await self._server.close()

async def resolve_stream_url(self, player_id: str, media: PlayerMedia) -> str:
Expand Down
97 changes: 57 additions & 40 deletions music_assistant/models/audio_analysis_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,13 @@ class AnalysisSessionData:


class AudioAnalysisProvider(Provider):
"""Base representation of an Audio Analysis Provider.

Audio Analysis Provider implementations should inherit from this base model.
These providers receive PCM audio chunks during streaming and produce analysis
results such as beat tracking, key detection, phrase boundaries, etc.
"""
Base representation of an Audio Analysis Provider.

The AudioAnalysisController creates session IDs and passes them to all methods.
Providers implement _start_analysis and _finalize as hooks — the base class
manages session lifecycle, version gating, and cleanup.
Receives PCM audio chunks during streaming and produces analysis results
such as beat tracking, key detection, or loudness. The same hooks drive
both live playback and background scans; providers do not need to know
which context they are running in.
"""

# Version of the analysis algorithm. Providers should increment this when
Expand All @@ -61,10 +59,9 @@ async def start_analysis(
streamdetails: StreamDetails,
audio_format: AudioFormat,
) -> bool:
"""Start analysis for a new session.
"""
Start analysis for a new session.

Checks whether analysis is needed (version gating), stores session data,
and calls _start_analysis for provider-specific initialization.
Returns True if the provider accepted the session.

:param session_id: Session ID created by the AudioAnalysisController.
Expand Down Expand Up @@ -95,11 +92,10 @@ async def _start_analysis(
streamdetails: StreamDetails,
audio_format: AudioFormat,
) -> bool:
"""Provider-specific initialization for a new analysis session.
"""
Provider-specific initialization for a new analysis session.

Called by start_analysis after version gating and session storage.
Return False to reject the session (e.g. unsupported format).
Session data is available in self._sessions[session_id].

:param session_id: The analysis session ID.
:param streamdetails: The stream details for the item being analyzed.
Expand All @@ -112,53 +108,74 @@ async def process_pcm_chunk(
session_id: str,
pcm_chunk: bytes,
) -> None:
"""Process a PCM audio chunk.
"""
Process a PCM audio chunk.

Called for each chunk of audio data during streaming.
Implementations MUST `await` all chunk-processing work; the controller
relies on this to backpressure the audio source.

:param session_id: The analysis session ID.
:param pcm_chunk: Raw PCM audio data.
"""

@abstractmethod
async def _finalize(self, session_id: str) -> None:
"""Finalize analysis and store results.
async def _finalize(self, session_id: str) -> AudioAnalysisData | None:
"""
Compute and return the analysis for this session (or None to skip).

Called when the track has finished streaming. Providers are responsible
for storing their results via mass.streams.audio_analysis.set_audio_analysis().
The base class persists the returned value via set_audio_analysis() and
then fires post_analysis(). Return None to skip both.

:param session_id: The analysis session ID.
"""

async def finalize(self, session_id: str) -> None:
"""Finalize analysis and clean up session state.

Calls _finalize, then removes the session from _sessions.
The controller calls this method — providers override _finalize.

:param session_id: The analysis session ID.
"""
"""Finalize analysis, persist the result, fire post_analysis, then clean up."""
analysis: AudioAnalysisData | None = None
try:
await self._finalize(session_id)
finally:
self._sessions.pop(session_id, None)
analysis = await self._finalize(session_id)
except Exception as err:
self.logger.error("_finalize raised for session %s: %s", session_id, err, exc_info=err)
session = self._sessions.get(session_id)
if analysis is not None and session is not None:
try:
await self.mass.streams.audio_analysis.set_audio_analysis(
item_id=session.streamdetails.item_id,
provider_instance_id_or_domain=session.streamdetails.provider,
aa_provider_domain=self.domain,
analysis=analysis,
analysis_version=self.analysis_version,
media_type=session.streamdetails.media_type,
)
except Exception as err:
self.logger.warning(
"set_audio_analysis raised for %s: %s", self.domain, err, exc_info=err
)
else:
try:
await self.post_analysis(session.streamdetails, analysis)
except Exception as err:
self.logger.warning(
"post_analysis raised for %s: %s", self.domain, err, exc_info=err
)
self._sessions.pop(session_id, None)

async def analyze_file(
async def post_analysis(
self,
streamdetails: StreamDetails,
) -> AudioAnalysisData | None:
analysis: AudioAnalysisData,
) -> None:
"""
Run analysis directly on a local audio file.
Run side effects after analysis is finalized and persisted.

Used by the AudioAnalysisController's background scan. Providers that can
analyze a file without going through live PCM streaming (e.g. by handing
the path to FFmpeg/librosa/etc.) should override this. Default returns
None, meaning the provider does not support file-based analysis.
Default is a no-op. Implementations MUST self-gate on whether
`streamdetails.path` is a writable filesystem path, since this hook
fires for both live and background-scan analyses.

:param streamdetails: StreamDetails for the item being analyzed.
Contains the local file path and audio format.
:param streamdetails: The stream details for the analyzed item.
:param analysis: The analysis data that was persisted by `_finalize`.
"""
return None
return

async def cancel(self, session_id: str) -> None:
"""Cancel an in-progress analysis session."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ class DemoAudioAnalysisProvider(AudioAnalysisProvider):
The base class uses this to skip re-analysis of already-analyzed tracks.
- If you have other conditions that determine whether to skip an analysis,
implement them in _start_analysis and return False to reject the session.
- Store results via self.mass.streams.audio_analysis.set_audio_analysis() in _finalize.
- Return AudioAnalysisData from _finalize; the base class persists it.
"""

# Increment this when your analysis algorithm changes significantly.
Expand Down Expand Up @@ -162,26 +162,18 @@ async def process_pcm_chunk(
)

async def _finalize(self, session_id: str) -> None:
"""Finalize analysis and store results.
"""Finalize analysis and return the result.

Called when the track has finished buffering and all chunks have been
processed. This is where a real provider would compute final results
and store them via self.mass.streams.audio_analysis.set_audio_analysis().
processed. A real provider would compute its final result and return it
as an AudioAnalysisData; the base class then persists it via
set_audio_analysis() and fires post_analysis(). Return None to skip both.

Example of storing results (not done in this demo)::
Example return (not done in this demo)::

from music_assistant.models.audio_analysis import AudioAnalysisData

session = self._sessions[session_id]
analysis = AudioAnalysisData(bpm=120.0, duration=180.5)
await self.mass.streams.audio_analysis.set_audio_analysis(
item_id=session.streamdetails.item_id,
provider_instance_id_or_domain=session.streamdetails.provider,
aa_provider_domain=self.domain,
analysis=analysis,
analysis_version=self.analysis_version,
media_type=session.streamdetails.media_type,
)
return AudioAnalysisData(bpm=120.0, duration=180.5)

Note: The base class's finalize() method calls this, then cleans up
the session from self._sessions automatically. Do not override finalize()
Expand Down
Loading
Loading