U R recognised
A music recommender that works out why you skipped: bad song, or wrong moment?
Live mood detection built on its own audio analysis. No API keys, no ratings, no thumbs.
Every music recommendation system knows that you skipped a track. Almost none know why. So they treat two opposite facts as the same thumbs down:
"I hate this song." and "I love this song, it is wrong for right now."
Conflating those is why skipping a favourite once can bury it for months. UReco splits them apart using signals it can actually observe: how far into the track you got, whether you have ever finished it before, whether it matched the mood you were already in, and whether you happen to be skipping everything right now.
There are no rating buttons. The only input is how long you stayed before moving on.
git clone https://github.com/codeC02003/UReco.git
cd UReco
pip install -r requirements.txt
python seed.py # build a pool from Deezer charts
python -m uvicorn ureco.server:app --port 8420 # then open http://127.0.0.1:8420No API keys and no OAuth. Deezer's public API needs neither, and ships a 30 second MP3 preview per track which is downloaded and analysed locally.
python -m pytest # 74 tests
python sim.py # simulated listener: does the queue learn?
python sim_open.py # does remembering a listener actually help?
python seed.py --collection bollywood # curated collectionsPoint it at your own music. Deezer's public API caps at exactly 30 seconds per track and there is no full length endpoint, which is a licensing limit rather than a missing feature. Spotify's Web Playback SDK does play whole songs, but it plays them inside a black box with no access to the samples, which would remove the one thing this project is built on.
Local files are the only source that gives you both the whole song and the audio to analyse, and they sit in the same pool as streamed tracks:
# use "Add your own music" in the sidebar, or:
curl -X POST localhost:8420/api/library -H 'Content-Type: application/json' \
-d '{"folder": "~/Music"}'A full file is also better to analyse than a preview. The analysis window is taken from the middle of the track rather than a fixed slice near the start, so an intro heavy song is not judged on its intro.
Spotify removed /audio-features, /audio-analysis and /recommendations for all new
applications in November 2024. There is no API left to ask. So every number here is computed
from the waveform with librosa, and reduced to eight axes a person can read:
| axis | low to high | measured from |
|---|---|---|
energy |
quiet, loud | RMS, onset rate, energy above 2 kHz |
tempo |
slow, fast | beat tracked BPM |
pulse |
loose, driving | tempogram autocorrelation peak |
density |
sparse, busy | onsets per second |
brightness |
dark, bright | spectral centroid and rolloff |
roughness |
smooth, gritty | zero crossing rate, flatness, percussive share |
dynamics |
flat, dynamic | dB gap between quiet and loud moments |
lift |
heavy, upbeat | major and minor lean, Krumhansl-Kessler profiles |
pulse reads 0.00 for a Chopin nocturne and 0.92 for a Calvin Harris single.
dynamics reads -3 dB for brickwalled metal and -15 dB for acoustic jazz.
In rough order of how much each signal tells you:
- Have you ever finished this track before? The strongest signal available. If you have, you demonstrably like it, so skipping it now is about your state, not your taste.
- How far in did you get? Inside the first 15% is rejection on contact. A third of the way in means you gave it a real chance and it stopped working.
- Did it match the mood you were already in? A track that sat where you were listening, that you still bailed on, is about the song.
- Are you skipping everything? Four skips in a row is not four bad songs.
- Heard it three times this week? Loved it, wore it out. Temporary.
Verdicts: completed engaged replayed, mood_mismatch context_shift not_now
satiated, dislike hate
The rule the whole design rests on: a taste verdict never moves the mood, and a mood verdict never damages an artist or counts towards a ban. Learning that you dislike a song tells you nothing about whether you want something calmer right now.
| what you do | what it concludes |
|---|---|
| move on within seconds, twice | that is the song. Dropped. |
| move on midway, every time it comes round | forgiven as "wrong moment" three times. On the fourth, the moments were all different and the answer never changed, so it goes. |
| move on from something you once played through | always the moment, never the song. Never dropped, however often it happens. |
Learning is split so that songs stay temporary and the listener does not:
Remembered. Your bail point, how fast your mood turns, which axes decide your skips, where you sit per genre and per hour, and the recurring moods you return to.
Forgotten each session. The current mood, dropped songs, per track counts, artist affinity.
The profile holds no track id and no artist name. A song dropped last Tuesday starts today with a clean slate.
Alongside it, a model trained on your own plays predicts whether you will stay past your own halfway bar. It is blended into the ranking in proportion to how well it has actually done, cross validated against its own shuffled baseline, and capped at 0.65 so a bad patch of data can never take the queue over. Cold, the rules run everything.
Searching for a track is the most deliberate thing you can do here, so it outranks whatever the session had settled into. The song's audio profile becomes the mood anchor, strongly at first and fading as real listening replaces it, and the queue continues from that sound.
A curated collection will lock the queue, because it is a real musical grouping. A raw Deezer genre will not, because it is too coarse to cage a mood in: a Bollywood ballad resolves to "Films/Games", which it shares with Western film scores.
The first track you play locks the session's genre. Everything after is chosen inside it while all eight axes still move freely, so you can start on a jazz record and stay in jazz while the queue follows you from sparse and dark towards busy and bright.
Collections exist because Deezer has no Bollywood genre. Checked against real tracks, the repertoire scatters across six genres at once:
Films/Games 17 Indian Music 4 (none) 2
Pop 7 Asian Music 4 Dance 2
A collection is a curated artist list tagged with an id of its own, which the lock treats like any genre. Bollywood, Classic Bollywood, Punjabi and Tamil ship with it.
sim.py runs a synthetic listener with a hidden preference against a pool where 25% of
tracks would satisfy them by chance, then flips their mood halfway through.
40.5% of tracks played past the halfway bar, against a 25% random baseline. That is 1.6 times random, measured over 12 seeds on a 297 track pool. A good run climbs from around 10% at cold start to around 67% once it has a read.
sim_open.py tests whether remembering a listener helps, using a listener with two genuine
moods who picks one each evening. 24 listeners, 6 sessions each:
| arm | whole session | first 8 tracks |
|---|---|---|
| habit memory only | 55% ±1.7 | 42% ±2.8 |
| reads the mood | 55% ±1.6 | 47% ±3.0 |
| oracle (told the answer) | 63% ±1.1 | 75% ±2.1 |
Read honestly, mood reading is worth about +5 points at session open at roughly 1.2 standard errors, which is suggestive rather than established. The oracle row is the interesting one. Simply knowing which mood is live is worth +33 points, so the information is enormously valuable and the detector currently delivers a fraction of it. The engineering notes work through exactly why.
Kept in the repo because a negative result is worth more than a feature nobody checked.
- Mood extrapolation. Aiming ahead of a listener whose energy is climbing reads well and
does not work, costing 3 to 4 points even against a listener who genuinely drifts. The
slope is fitted to six 8 dimensional vectors off 30 second previews, so extrapolating
mostly amplifies noise.
VELOCITY_HORIZON = 0.0. - Predicting listened ratio with a linear model. Cross validated R² of exactly 0.000 on data generated from those very axes. Listening is bimodal, since people either bail in seconds or stay for most of a song, and liking is peaked, so a signed delta cannot express it. Fixed by predicting "will they stay past halfway" from the absolute per axis gap.
- Clustering moods over tracks rather than sessions. No better, at 49% to 44% of true separation.
- Probing from the first track. Chose informative openers over enjoyable ones even when the hour already said 82% which mood it was.
- Previews are 30 seconds. "Played 50%" means 15 seconds, and an intro heavy track is judged on its intro. Local files do not have this problem.
- The simulated listener is not a person. It reacts to feature distance with Gaussian noise. No lyrics, no memories, no friends in the room.
liftis a major and minor lean, not valence, and it is Western tonal theory, so it means less for repertoire built on ragas.- Tempo comes back an octave out sometimes. Portishead's "Roads" reads 152 BPM against a true 76.
- Cold start is genuinely cold, and the interface says so rather than pretending.
ureco/
audio.py preview or file, to waveform, to the eight axes
features.py the mood space, distance, per listener axis weighting
skip.py why a skip happened <- the core idea
mood.py live mood, and what may move it
modes.py the moods you return to, and reading which one is live
profile.py what persists between sessions (no track ids)
learner.py a model of you, gated so that it has to earn its influence
taste.py long run affinity, Beta posteriors, fatigue
recommender.py scoring and ranking
engine.py play, classify, update, re-rank, explain
explain.py turns the numbers into sentences
catalog.py Deezer client, rate limited, with collections
local.py your own files, analysed at full length
store.py SQLite, one connection per thread
server.py FastAPI
web/ player interface: transport, queue, live mood axes, reasoning feed
Stack: Python 3.12, librosa, numpy, scikit-learn, FastAPI, SQLite, vanilla JavaScript.
Full engineering write up, including the bugs a simulator caught that unit tests did not: docs/ENGINEERING.md
