2026-02-22 14:04:01 +00:00
# haunt-fm
2026-02-22 12:36:40 -06:00
Personal music recommendation engine that captures listening history from Music Assistant, discovers similar music via Last.fm, computes audio embeddings with CLAP, and generates playlists mixing known favorites with new discoveries — played back on house speakers via Apple Music.
## How It Works
```
You play music on any speaker
→ HA automation logs the listen event
→ Last.fm discovers similar tracks (~50 per listened track)
→ iTunes Search API finds 30-second audio previews
→ CLAP model computes 512-dim audio embeddings
→ pgvector stores and indexes embeddings (HNSW cosine similarity)
→ Taste profile = weighted average of listened-track embeddings
→ Recommendations = closest unheard tracks by cosine similarity
→ Playlist mixes known favorites + new discoveries
→ Music Assistant plays it on speakers via Apple Music
```
## Deployment
Runs on the NAS as two Docker containers:
| Container | Image | Port | Purpose |
|-----------|-------|------|---------|
| `haunt-fm` | Custom build | 8321 → 8000 | FastAPI app + embedding worker |
| `haunt-fm-db` | `pgvector/pgvector:pg17` | internal | PostgreSQL + pgvector |
```bash
# Deploy / rebuild
cd /volume1/homes/antialias/projects/haunt-fm
git pull && docker-compose up -d --build haunt-fm
# Run migrations
docker exec haunt-fm alembic upgrade head
```
**Access:**
- Status page: https://recommend.haunt.house
- Health check: http://192.168.86.51:8321/health
- API status: http://192.168.86.51:8321/api/status
- Source: https://git.dev.abaci.one/antialias/haunt-fm
## API Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/health` | Health check (DB connectivity) |
| GET | `/api/status` | Full pipeline status JSON |
| GET | `/` | HTML status dashboard |
| POST | `/api/history/webhook` | Log a listen event (from HA automation) |
2026-02-22 19:14:34 -06:00
| GET | `/api/history/recent?limit=20&profile=name` | Recent listen events (optional profile filter) |
2026-02-22 12:36:40 -06:00
| POST | `/api/admin/discover` | Expand listening history via Last.fm |
| POST | `/api/admin/build-taste-profile` | Rebuild taste profile from embeddings |
2026-02-22 13:14:28 -06:00
| GET | `/api/recommendations?limit=50&vibe=chill+ambient` | Get ranked recommendations (optional vibe) |
2026-02-22 12:36:40 -06:00
| POST | `/api/playlists/generate` | Generate and optionally play a playlist |
2026-02-22 19:14:34 -06:00
| GET | `/api/profiles` | List all named profiles with stats |
| POST | `/api/profiles` | Create a named profile |
| GET | `/api/profiles/{name}` | Get profile details + stats |
| DELETE | `/api/profiles/{name}` | Delete profile (reassigns events to default) |
| PUT | `/api/profiles/{name}/speakers` | Set speaker→profile mappings |
| GET | `/api/profiles/{name}/speakers` | List speaker mappings |
2026-02-22 12:36:40 -06:00
## Usage
### Generate and play a playlist
```bash
curl -X POST http://192.168.86.51:8321/api/playlists/generate \
-H "Content-Type: application/json" \
-d '{
"total_tracks": 20,
"known_pct": 30,
"speaker_entity": "media_player.living_room_speaker_2",
"auto_play": true
}'
```
2026-02-22 13:14:28 -06:00
### Generate a vibe-based playlist
```bash
curl -X POST http://192.168.86.51:8321/api/playlists/generate \
-H "Content-Type: application/json" \
-d '{
"total_tracks": 15,
"vibe": "chill ambient lo-fi",
"speaker_entity": "media_player.living_room_speaker_2",
"auto_play": true
}'
```
2026-02-22 12:36:40 -06:00
**Parameters:**
- `total_tracks` — number of tracks in the playlist (default 20)
- `known_pct` — percentage of known-liked tracks vs new discoveries (default 30)
- `speaker_entity` — Music Assistant entity ID (must be a `_2` suffix entity)
- `auto_play` — `true` to immediately play on the speaker
2026-02-22 13:14:28 -06:00
- `vibe` — text description of the desired mood/vibe (e.g. "chill lo-fi beats", "upbeat party music"). Uses CLAP text embeddings to match tracks in the same vector space as audio.
- `alpha` — blend factor between taste profile and vibe (default 0.5). `1.0` = pure taste profile, `0.0` = pure vibe match, `0.5` = equal blend. Ignored when no vibe is provided.
2026-02-22 19:14:34 -06:00
- `profile` — named taste profile to use (default: "default"). Each profile has its own listening history and taste embedding.
2026-02-22 12:36:40 -06:00
### Speaker entities
The `speaker_entity` **must ** be a Music Assistant entity (the `_2` suffix ones) for text search to resolve through Apple Music. Raw Cast entities cannot resolve search queries.
| Speaker | Entity ID |
|---------|-----------|
| Living Room speaker | `media_player.living_room_speaker_2` |
| Dining Room speaker | `media_player.dining_room_speaker_2` |
| basement mini | `media_player.basement_mini_2` |
| Kitchen stereo | `media_player.kitchen_stereo_2` |
| Study speaker | `media_player.study_speaker_2` |
| Butler's Pantry speaker | `media_player.butlers_pantry_speaker_2` |
| Master bathroom speaker | `media_player.master_bathroom_speaker_2` |
| Kids Room speaker | `media_player.kids_room_speaker_2` |
| Guest bedroom speaker 2 | `media_player.guest_bedroom_speaker_2_2` |
| Garage Wifi | `media_player.garage_wifi_2` |
| Whole House | `media_player.whole_house_2` |
| downstairs | `media_player.downstairs_2` |
| upstairs | `media_player.upstairs_2` |
2026-02-22 19:14:34 -06:00
### Named profiles
Named profiles let each household member get personalized recommendations without polluting each other's taste.
```bash
# Create a profile
curl -X POST http://192.168.86.51:8321/api/profiles \
-H "Content-Type: application/json" \
-d '{"name":"antialias","display_name":"Me"}'
# Map speakers to auto-attribute listens
curl -X PUT http://192.168.86.51:8321/api/profiles/antialias/speakers \
-H "Content-Type: application/json" \
-d '{"speakers":["Study speaker","Master bathroom speaker"]}'
# Log a listen event with explicit profile
curl -X POST http://192.168.86.51:8321/api/history/webhook \
-H "Content-Type: application/json" \
-d '{"title":"Song","artist":"Artist","profile":"antialias"}'
# Get recommendations for a profile
curl "http://192.168.86.51:8321/api/recommendations?limit=20&profile=antialias"
# Generate playlist for a profile
curl -X POST http://192.168.86.51:8321/api/playlists/generate \
-H "Content-Type: application/json" \
-d '{"total_tracks":20,"profile":"antialias","speaker_entity":"media_player.study_speaker_2","auto_play":true}'
# Build taste profile manually
curl -X POST "http://192.168.86.51:8321/api/admin/build-taste-profile?profile=antialias"
```
All endpoints are backward compatible — omitting `profile` uses the "default" profile. Events with no profile assignment (including all existing events) belong to "default".
2026-02-22 12:36:40 -06:00
### Other operations
```bash
# Log a listen event manually
curl -X POST http://192.168.86.51:8321/api/history/webhook \
-H "Content-Type: application/json" \
-d '{"title":"Paranoid Android","artist":"Radiohead","album":"OK Computer"}'
# Run Last.fm discovery (expand candidate pool)
curl -X POST http://192.168.86.51:8321/api/admin/discover \
-H "Content-Type: application/json" \
-d '{"limit": 50}'
# Rebuild taste profile
curl -X POST http://192.168.86.51:8321/api/admin/build-taste-profile
# Get recommendations (without playing)
curl http://192.168.86.51:8321/api/recommendations?limit=20
2026-02-22 13:14:28 -06:00
# Get vibe-matched recommendations
curl "http://192.168.86.51:8321/api/recommendations?limit=20&vibe=dark+electronic&alpha=0.3"
2026-02-22 12:36:40 -06:00
```
## Pipeline Stages
1. **Listening History ** — HA automation POSTs to webhook when music plays on any Music Assistant speaker. Deduplicates events within 60 seconds.
2. **Discovery ** — Last.fm `track.getSimilar` expands each listened track to ~50 candidates.
3. **Preview Lookup ** — iTunes Search API finds 30-second AAC preview URLs (rate-limited ~20 req/min).
4. **Embedding ** — Background worker downloads previews, runs CLAP model (`laion/larger_clap_music` ), stores 512-dim vectors in pgvector with HNSW index.
5. **Taste Profile ** — Weighted average of listened-track embeddings (play count * recency decay).
6. **Recommendations ** — pgvector cosine similarity against taste profile, excluding known tracks.
7. **Playlist ** — Mix known-liked + new recommendations, interleave, play via Music Assistant.
## Improving Recommendations Over Time
Recommendations improve as the system accumulates more data:
- **Listen to music** — every track played on any speaker is logged automatically
- **Run discovery periodically** — `POST /api/admin/discover` to expand the candidate pool via Last.fm
- **Rebuild taste profile** — `POST /api/admin/build-taste-profile` after significant new listening activity
- **Embedding worker runs continuously** — new candidates are automatically downloaded and embedded
The taste profile is a weighted average of all listened-track embeddings. More diverse listening history = more nuanced recommendations.
## Tech Stack
| Component | Choice |
|-----------|--------|
| App framework | FastAPI + SQLAlchemy async + Alembic |
| Database | PostgreSQL 17 + pgvector (HNSW cosine similarity) |
| Embedding model | CLAP `laion/larger_clap_music` (512-dim, PyTorch CPU) |
| Audio previews | iTunes Search API (free, no auth, 30s AAC) |
| Discovery | Last.fm `track.getSimilar` API |
| Playback | Music Assistant via Home Assistant REST API |
| Music catalog | Apple Music (via Music Assistant) |
| Reverse proxy | Traefik (`recommend.haunt.house` ) |
## Environment Variables
All prefixed with `HAUNTFM_` . Key ones:
| Variable | Purpose |
|----------|---------|
| `HAUNTFM_DATABASE_URL` | PostgreSQL connection string |
| `HAUNTFM_LASTFM_API_KEY` | Last.fm API key for discovery |
| `HAUNTFM_HA_URL` | Home Assistant URL |
| `HAUNTFM_HA_TOKEN` | Home Assistant long-lived access token |
| `HAUNTFM_EMBEDDING_WORKER_ENABLED` | Enable/disable background embedding worker |
| `HAUNTFM_EMBEDDING_BATCH_SIZE` | Tracks per batch (default 10) |
| `HAUNTFM_EMBEDDING_INTERVAL_SECONDS` | Seconds between batch checks (default 30) |
| `HAUNTFM_MODEL_CACHE_DIR` | CLAP model cache directory |
| `HAUNTFM_AUDIO_CACHE_DIR` | Downloaded preview cache directory |
See `.env.example` for full list.
## Integrations
- **Home Assistant** — automation `haunt_fm_log_music_play` captures listening history; REST API used for speaker playback
- **Music Assistant** — resolves text search queries to Apple Music tracks, streams to Cast speakers
- **OpenClaw** — has a skill doc (`skills/haunt-fm/SKILL.md` ) so you can request playlists via Telegram/iMessage
- **Traefik** — routes `recommend.haunt.house` to the service
- **Porkbun DNS** — CNAME for `recommend.haunt.house`