Thomas Hallock 540862fcad Add profile-aware feedback with vibe context
Feedback buttons now target the listener's profile instead of the
dashboard filter profile. Adds a persistent vibe context input that
replaces the hardcoded "general" vibe. Shows listener profile badge
and track ID on each listen item. Adds manual feedback form for
submitting feedback on any track by ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:25:29 -06:00
2026-02-22 08:36:36 -06:00
2026-02-22 08:36:36 -06:00
2026-02-22 14:04:01 +00:00
2026-02-22 08:36:36 -06:00
2026-02-22 08:36:36 -06:00

haunt-fm

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
# 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:

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)
GET /api/history/recent?limit=20&profile=name Recent listen events (optional profile filter)
POST /api/admin/discover Expand listening history via Last.fm
POST /api/admin/build-taste-profile Rebuild taste profile from embeddings
GET /api/recommendations?limit=50&vibe=chill+ambient Get ranked recommendations (optional vibe)
POST /api/playlists/generate Generate and optionally play a playlist
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

Usage

Generate and play a playlist

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
  }'

Generate a vibe-based playlist

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
  }'

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_playtrue to immediately play on the speaker
  • 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.
  • profile — named taste profile to use (default: "default"). Each profile has its own listening history and taste embedding.

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

Named profiles

Named profiles let each household member get personalized recommendations without polluting each other's taste.

# 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".

Other operations

# 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

# Get vibe-matched recommendations
curl "http://192.168.86.51:8321/api/recommendations?limit=20&vibe=dark+electronic&alpha=0.3"

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 periodicallyPOST /api/admin/discover to expand the candidate pool via Last.fm
  • Rebuild taste profilePOST /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
Description
Personal music recommendation service — captures listening history, discovers similar tracks via Last.fm, embeds audio with CLAP, generates playlists
Readme 235 KiB
Languages
Python 72.8%
HTML 26.5%
Mako 0.4%
Dockerfile 0.3%