Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions backend/app/api/routes/recap.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,27 @@ class RecapAlbumStat(_FromService):
track_id: UUID


class RecapSoundPercentilesStat(_FromService):
"""Each median's percentile rank in the whole library, 0-100; 50 is the library's middle.

Present these rather than the raw medians: the analysis scores most of a library alike, so
an absolute value says little about one listener's year (ADR-0156 Implementation).
"""

bpm: int | None
energy: int | None
valence: int | None
danceability: int | None


class RecapSoundStat(_FromService):
bpm: float | None
energy: float | None
valence: float | None
danceability: float | None
key: str | None
mood_tags: list[str]
library_percentile: RecapSoundPercentilesStat


class RecapResponse(_FromService):
Expand Down
46 changes: 46 additions & 0 deletions backend/app/services/recap.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ class RecapAlbum:
track_id: UUID


@dataclass
class RecapSoundPercentiles:
"""Where each of the year's medians sits in the whole library, 0-100.

The raw medians cannot stand alone. The analysis scores this library high on energy and
valence and low on danceability throughout (library medians 0.83, 0.85 and 0.15 on the NAS,
2026-10-30), so "0.85 energy" means "typical", not "energetic". 50 is the library's middle.
"""

bpm: int | None = None
energy: int | None = None
valence: int | None = None
danceability: int | None = None


@dataclass
class RecapSound:
bpm: float | None = None
Expand All @@ -82,6 +97,7 @@ class RecapSound:
danceability: float | None = None
key: str | None = None
mood_tags: list[str] = field(default_factory=list)
library_percentile: RecapSoundPercentiles = field(default_factory=RecapSoundPercentiles)


@dataclass
Expand Down Expand Up @@ -308,9 +324,39 @@ async def _sound(db: AsyncSession, play_counts: Counter[UUID]) -> RecapSound:
if keys:
sound.key = keys.most_common(1)[0][0]
sound.mood_tags = [tag for tag, _ in tags.most_common(TOP_MOOD_TAGS)]
sound.library_percentile = await _library_percentiles(db, sound)
return sound


async def _library_percentiles(db: AsyncSession, sound: RecapSound) -> RecapSoundPercentiles:
"""Each median's percentile rank among every analysed track, ties counted as half.

The whole library, not this profile's plays: the question a slide answers is "was my year
calmer than my music", and the music is the library.
"""
percentiles = RecapSoundPercentiles()
medians = {name: getattr(sound, name) for name in _SOUND_FEATURES}
present = {name: value for name, value in medians.items() if value is not None}
if not present:
return percentiles

columns = []
for name, value in present.items():
column = getattr(TrackAnalysis, name)
columns += [
func.count(column),
func.count().filter(column < value),
func.count().filter(column == value),
]
row = (await db.execute(select(*columns))).one()

for i, name in enumerate(present):
total, below, equal = row[3 * i: 3 * i + 3]
if total:
setattr(percentiles, name, round(100 * (below + equal / 2) / total))
return percentiles


async def _first_listens(
db: AsyncSession, profile_id: UUID, start: datetime, heard: set[UUID]
) -> int:
Expand Down
2 changes: 1 addition & 1 deletion backend/contract.lock.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"contract_version": 1,
"min_client_contract": 1,
"schema_hash": "sha256:35c91c4ee610855229467569f2625bddcbad6ece349548b7ea62b6546ac6531d",
"schema_hash": "sha256:59b7e2bee3d2476285031a8d8151cd3ffb64510f24f09a2a4373b6b8e2d8b5a8",
"client_floor_history": [
{
"floor": 1,
Expand Down
47 changes: 46 additions & 1 deletion backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8117,6 +8117,47 @@
"title": "RecapResponse",
"type": "object"
},
"RecapSoundPercentilesStat": {
"description": "Each median's percentile rank in the whole library, 0-100; 50 is the library's middle.\n\nPresent these rather than the raw medians: the analysis scores most of a library alike, so\nan absolute value says little about one listener's year (ADR-0156 Implementation).",
"properties": {
"bpm": {
"title": "Bpm",
"type": [
"integer",
"null"
]
},
"danceability": {
"title": "Danceability",
"type": [
"integer",
"null"
]
},
"energy": {
"title": "Energy",
"type": [
"integer",
"null"
]
},
"valence": {
"title": "Valence",
"type": [
"integer",
"null"
]
}
},
"required": [
"bpm",
"energy",
"valence",
"danceability"
],
"title": "RecapSoundPercentilesStat",
"type": "object"
},
"RecapSoundStat": {
"properties": {
"bpm": {
Expand Down Expand Up @@ -8147,6 +8188,9 @@
"null"
]
},
"library_percentile": {
"$ref": "#/components/schemas/RecapSoundPercentilesStat"
},
"mood_tags": {
"items": {
"type": "string"
Expand All @@ -8168,7 +8212,8 @@
"valence",
"danceability",
"key",
"mood_tags"
"mood_tags",
"library_percentile"
],
"title": "RecapSoundStat",
"type": "object"
Expand Down
20 changes: 20 additions & 0 deletions backend/tests/test_recap.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,23 @@ def test_endpoint_rejects_an_unknown_time_zone(client, test_profile):

def test_endpoint_requires_a_profile(client):
assert client.get("/api/v1/recap/2026").status_code == 401


@pytest.mark.asyncio
async def test_the_sound_is_placed_within_the_whole_library(async_db):
"""A raw 0.85 energy is typical of this analysis; the percentile is what a slide can say."""
profile = await insert_test_profile(async_db)
played = await insert_test_track(async_db, title="Played")
await insert_test_analysis(async_db, played.id, {"energy": 0.3, "valence": 0.5})
# The rest of the library, never played: three tracks above the played one, one below.
for title, energy in [("A", 0.9), ("B", 0.8), ("C", 0.7), ("D", 0.1)]:
other = await insert_test_track(async_db, title=title)
await insert_test_analysis(async_db, other.id, {"energy": energy, "valence": 0.5})
await _plays(async_db, profile, played, 3)
await async_db.commit()

percentile = (await compute_recap(async_db, profile.id, 2026, UTC, now=NOW)).sound.library_percentile

assert percentile.energy == 30 # one below, itself counted as half, of five
assert percentile.valence == 50 # level with everything: the middle
assert percentile.bpm is None # no median, no rank
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ Implementation:
- **Point 6's raw medians are not yet meaningful on their own.** The whole library's median energy
is 0.83, valence 0.85 and danceability 0.15, so "0.85 energy" means "typical for this library".
A client must not present these values as absolutes.
- **`sound.library_percentile` added, 2026-10-30.** For each median, it gives the percentile rank
among every analysed track in the library, 0-100, with ties counted as half. A slide says
"calmer than your library" from this, not "85% energetic". The change is additive and the
contract stays at version 1.

Extends [ADR-0004](ADR-0004-listening-feedback-is-event-sourced.md)

Expand Down
2 changes: 1 addition & 1 deletion packages/api-client/src/generated/index.ts

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions packages/api-client/src/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5774,6 +5774,33 @@ export type RecapResponse = {
year: number;
};

/**
* RecapSoundPercentilesStat
*
* Each median's percentile rank in the whole library, 0-100; 50 is the library's middle.
*
* Present these rather than the raw medians: the analysis scores most of a library alike, so
* an absolute value says little about one listener's year (ADR-0156 Implementation).
*/
export type RecapSoundPercentilesStat = {
/**
* Bpm
*/
bpm: number | null;
/**
* Danceability
*/
danceability: number | null;
/**
* Energy
*/
energy: number | null;
/**
* Valence
*/
valence: number | null;
};

/**
* RecapSoundStat
*/
Expand All @@ -5794,6 +5821,7 @@ export type RecapSoundStat = {
* Key
*/
key: string | null;
library_percentile: RecapSoundPercentilesStat;
/**
* Mood Tags
*/
Expand Down
Loading