From f2a443c6e3a04c5662ec14938389f9dac9ca62ea Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 6 Feb 2026 09:59:51 -0600 Subject: [PATCH] v1.4.0: sequential enrichment, GPU auto-detection, repo cleanup - Run audio analysis and vibe embedding phases sequentially to prevent resource contention (CPU/memory) from concurrent analyzers - Auto-detect GPU availability in both audio analyzers (CUDA/ROCm) - Fix false lite mode detection on startup by checking analyzer scripts on disk before falling back to heartbeat/DB checks - Fix Dockerfile NEXT_PUBLIC_BACKEND_URL and frontend rewrite proxy - Route enrichment failures through notification system instead of persistent error banner - Remove playback error banner from player components - Reduce enrichment cycle interval from 6h to 2h - Comprehensive repo cleanup: remove 127 decorative comment dividers across 17 files, clean verbose comments, harden .gitignore, remove tracked docs from git Co-Authored-By: Claude Opus 4.6 --- .gitignore | 383 +----- CHANGELOG.md | 61 + Dockerfile | 2 +- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/routes/browse.ts | 20 - backend/src/routes/library.ts | 12 +- backend/src/routes/mixes.ts | 4 - backend/src/routes/notifications.ts | 4 - backend/src/routes/playlists.ts | 4 - backend/src/routes/recommendations.ts | 5 +- backend/src/services/acquisitionService.ts | 8 - backend/src/services/audiobookCache.ts | 16 +- backend/src/services/deezer.ts | 20 - backend/src/services/discoverWeekly.ts | 17 - backend/src/services/featureDetection.ts | 15 + backend/src/services/lidarr.ts | 29 - backend/src/services/programmaticPlaylists.ts | 15 +- backend/src/workers/artistEnrichment.ts | 1 - backend/src/workers/unifiedEnrichment.ts | 322 ++--- docs/plans/2026-02-02-vibe-search-accuracy.md | 1178 ----------------- frontend/app/vibe/page.tsx | 28 - frontend/components/player/FullPlayer.tsx | 35 +- frontend/components/player/MiniPlayer.tsx | 39 - frontend/components/player/OverlayPlayer.tsx | 20 - frontend/hooks/useQueries.ts | 59 - frontend/lib/api.ts | 4 - frontend/next.config.ts | 4 +- frontend/package-lock.json | 65 +- frontend/package.json | 2 +- package-lock.json | 22 +- package.json | 5 + services/audio-analyzer-clap/analyzer.py | 17 +- services/audio-analyzer/analyzer.py | 32 +- 34 files changed, 412 insertions(+), 2042 deletions(-) delete mode 100644 docs/plans/2026-02-02-vibe-search-accuracy.md create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 6c3d79b..23474f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,404 +1,135 @@ -# ============================================================================= -# LIDIFY MONOREPO - .gitignore -# ============================================================================= - -# ============================================================================= -# Environment Variables & Secrets -# ============================================================================= +# Environment & Secrets .env .env.* !.env.example *.local -.env.development.local -.env.test.local -.env.production.local -.env.local .roomodes -# ============================================================================= # Dependencies -# ============================================================================= -# Node modules in all subdirectories **/node_modules/ -node_modules/ -jspm_packages/ - -# Python virtual environments (for soularr, scripts) **/__pycache__/ *.py[cod] *$py.class *.so .Python venv/ -env/ -ENV/ .venv/ **/.venv/ -# ============================================================================= -# Build -# ============================================================================= -# Frontend (Next.js) +# Build Outputs frontend/.next/ frontend/out/ -frontend/build/ -frontend/dist/ - -# Backend (Node.js/TypeScript) backend/dist/ -backend/build/ -backend/out/ - -# Mobile Application -mobile-application/build/ -mobile-application/dist/ -mobile-application/.expo/ -mobile-application/.expo-shared/ - -# Soularr -soularr/dist/ -soularr/build/ - -# General build outputs **/dist/ **/build/ **/out/ .next -.nuxt -# ============================================================================= # Logs -# ============================================================================= -logs +logs/ *.log npm-debug.log* yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* pnpm-debug.log* +backend/logs/ +frontend/logs/ -# ============================================================================= # Testing & Coverage -# ============================================================================= coverage/ *.lcov .nyc_output -*.tsbuildinfo -.cache/ +**/playwright-report/ +**/test-results/ -# ============================================================================= -# Cache Directories -# ============================================================================= +# Temporary & Backup Files +*.tmp +*.temp +*.bak +*.old + +# Cache .cache -.parcel-cache .eslintcache .stylelintcache -.npm -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ +.rpt2_cache_*/ +.ruff_cache/ -# ============================================================================= -# Docker & Containers -# ============================================================================= -# Don't ignore docker-compose.yml itself, but ignore local overrides +# Docker docker-compose.override.yml docker-compose.local.yml - -# Docker volumes (if any are stored locally) **/volumes/ **/data/ -# ============================================================================= # IDEs & Editors -# ============================================================================= -# VSCode .vscode/ -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace -.vscode-test - -# Claude Code -.claude/ -.claude/* -!.claude/commands/ - -# JetBrains IDEs (WebStorm, IntelliJ, etc.) .idea/ *.iml -*.iws -*.ipr - -# Sublime Text *.sublime-workspace *.sublime-project - -# Vim *.swp *.swo *~ - -# Emacs -*~ \#*\# .\#* +*.code-workspace -# macOS +# OS Files .DS_Store -.AppleDouble -.LSOverride -._* - -# Windows Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db Desktop.ini -$RECYCLE.BIN/ -# ============================================================================= -# Runtime & Process Files -# ============================================================================= -pids +# Runtime & Process +pids/ *.pid *.seed *.pid.lock -# ============================================================================= -# Database Files (SQLite for local development) -# ============================================================================= +# Database *.sqlite *.sqlite3 *.db *.db-shm *.db-wal - -# Prisma **/prisma/dev.db **/prisma/dev.db-journal -# ============================================================================= -# Media & Large Files -# ============================================================================= -# Don't commit large music files (if any test files are added) -*.mp3 -*.flac -*.wav -*.m4a -*.ogg -*.opus +# TypeScript +*.tsbuildinfo -# ============================================================================= -# Secrets & Key Material -# ============================================================================= -keystore.b64 -keystore.jks -*.keystore -*.jks - -# ============================================================================= -# Runtime caches (backend) -# ============================================================================= -backend/cache/ -backend/logs/ -backend/mullvad/ - -# ============================================================================= -# Test artifacts -# ============================================================================= -**/playwright-report/ -**/test-results/ - -# Don't commit large images (unless they're assets) -# *.jpg -# *.jpeg -# *.png -# *.gif - -# ============================================================================= -# Temporary Files -# ============================================================================= -*.tmp -*.temp -*.swp -*.swo -*.bak -*.old - -# ============================================================================= -# Package Manager Files -# ============================================================================= -# Yarn +# Package Manager .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* -yarn-error.log - -# NPM -npm-debug.log* - -# PNPM pnpm-lock.yaml -.pnpm-debug.log* -# ============================================================================= -# Mobile Specific (React Native / Expo) -# ============================================================================= -mobile-application/.expo/ -mobile-application/.expo-shared/ -mobile-application/android/app/build/ -mobile-application/ios/Pods/ -mobile-application/ios/build/ -mobile-application/*.jks -mobile-application/*.keystore -mobile-application/*.p8 -mobile-application/*.p12 -mobile-application/*.mobileprovision +# Secrets & Keys +*.keystore +*.jks +keystore.b64 +**/key.txt +*.conf +lidify.keystore -# Legacy native leftovers (web app is PWA-first) -frontend/android/ - -# ============================================================================= -# Postman (Keep collections, ignore environments with secrets) -# ============================================================================= -postman/*environment*.json -postman/*.local.json - - - -# BUT allow README.md files (case-insensitive) -!README.md -!readme.md -!Readme.md - -# ============================================================================= -# TypeScript -# ============================================================================= -*.tsbuildinfo -tsconfig.tsbuildinfo - -# ============================================================================= -# Miscellaneous -# ============================================================================= -.lock-wscript -lib-cov -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -.grunt -bower_components -.serverless/ -.fusebox/ -.dynamodb/ -.tern-port -.docusaurus -**/.vitepress/dist -**/.vitepress/cache -.vuepress/dist -.temp -*.tgz -.node_repl_history - -# ============================================================================= -# Project Specific -# ============================================================================= -# Development scripts (keep locally, don't commit) -reset-and-setup.sh -organize-singles.sh - -# AI Context Management (keep locally, don't push to GitHub) -context_portal/ - -# Internal Development Documentation (keep locally, don't push to GitHub) -docs/ -**/docs/ - - -# Temporary commit messages -COMMIT_MESSAGE.txt - -# Backend development logs -backend/logs/ - -# Backend test cache directories -backend/cache/test-*/ - -# Backend duplicate/nested directories -backend/backend/ - -# Frontend Android build artifacts -frontend/android/build/ -frontend/android/app/build/ - -# Postman collections (removed from repo) -postman/ - -# Soularr config (removed from repo) -soularr/ - -# Legacy React Native files (if re-added) -/App.tsx -/app.json -/src/ - -# ============================================================================= -# IDE & Editor Settings -# ============================================================================= -.claude/ -**/.claude/ -.cursor/ -**/.cursor/ -.vscode/ -**/.vscode/ -.roo/ -**/.roo/ - -# ============================================================================= -# Android Build Artifacts (contains local paths) -# ============================================================================= -frontend/android/app/build/ -frontend/android/build/ -frontend/android/.gradle/ -frontend/android/app/src/main/assets/capacitor.config.json - -# ============================================================================= -# Capacitor Generated Files -# ============================================================================= -frontend/android/capacitor-cordova-android-plugins/build/ - -# ============================================================================= -# Cache Files (user-specific data) -# ============================================================================= +# Backend backend/cache/ **/cache/covers/ **/cache/transcodes/ - -# ============================================================================= -# VPN / Private Configs (NEVER commit these!) -# ============================================================================= -backend/mullvad/ **/mullvad/ -*.conf -**/key.txt -# Android signing -lidify.keystore -keystore.b64 +# Frontend +frontend/android/ +frontend/test-*.tsx -# ============================================================================= # AI Tools & Assistants -# ============================================================================= +.claude/ +**/.claude/ +!.claude/commands/ +.cursor/ +**/.cursor/ +.roo/ +**/.roo/ .aider* .serena/ **/.serena/ @@ -408,18 +139,30 @@ keystore.b64 **/.vibe/ .claudeignore CLAUDE.md +AGENTS.md -# AI-generated planning/reports/documentation (keep locally) +# Internal Documentation (public docs are in project root) +docs/ +**/docs/ +context_portal/ issues/ plans/ systems/ -# Development worktrees (keep locally, don't commit) +# Development .worktrees/ +reset-and-setup.sh +organize-singles.sh +COMMIT_MESSAGE.txt +backend/backend/ +postman/ +soularr/ -# ============================================================================= -# Development/Debug Test Files (keep locally, don't push to GitHub) -AGENTS.md -# ============================================================================= -frontend/test-*.tsx -frontend/features/library/components/LazyArtistCard.tsx +# Legacy (removed from repo) +/App.tsx +/app.json +/src/ + +# Allow README files +!README.md +!readme.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6480831..5936e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to Lidify will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2026-02-05 + +### Performance + +- **Sequential audio/vibe enrichment:** Vibe phase skips when audio analysis is still running, preventing concurrent CPU-intensive Python analyzers from competing for resources +- **Faster enrichment cycles:** Reduced cycle interval from 30s to 5s; the rate limiter already handles API throttling, making the extra delay redundant +- **GPU auto-detection (CLAP):** PyTorch-based CLAP vibe embeddings auto-detect and use GPU when available, falling back to CPU +- **GPU auto-detection (Essentia):** TensorFlow-based audio analysis detects GPU with memory growth enabled, with device logging on startup + +### Changed + +- **Enrichment orchestration simplified:** Replaced 4 phase functions with duplicated stop/pause handling with a generic `runPhase()` executor and `shouldHaltCycle()` helper + +### Fixed + +- **Docker frontend routing:** Fixed `NEXT_PUBLIC_BACKEND_URL` build-time env var in Dockerfile so the frontend correctly proxies API requests to the backend +- **Next.js rewrite proxy:** Updated rewrite config to use `NEXT_PUBLIC_BACKEND_URL` for consistent build-time/runtime behavior +- **False lite mode on startup:** Feature detection now checks for analyzer scripts on disk, preventing false "lite mode" display before analyzers send their first heartbeat +- **Removed playback error banner:** Removed the red error bar from all player components (FullPlayer, MiniPlayer, OverlayPlayer) that displayed raw Howler.js error codes +- **Enrichment failure notifications:** Replaced aggressive per-cycle error banner with a single notification through the notification system when enrichment completes with failures + +## [1.3.9] - 2026-02-04 + +### Fixed + +- **Audio analysis cleanup:** Fixed race condition in audio analysis cleanup that could reset tracks still being processed + +## [1.3.8] - 2026-02-03 + +### Fixed + +- **Enrichment:** CLAP queue and failure cleanup fixes for enrichment debug mode + ## [1.3.7] - 2026-02-01 ### Added @@ -69,6 +102,34 @@ Automatic detection of available analyzers with graceful degradation. - **Docker Profiles:** Replaced Docker profiles with override file approach for better compatibility - **Mood Columns:** Marked as legacy in schema - may be derived from CLAP embeddings in future +## [1.3.5] - 2026-01-22 + +### Fixed + +- **Audio preload:** Emit preload 'load' event asynchronously to prevent race condition during gapless playback + +## [1.3.4] - 2026-01-22 + +### Added + +- **Gapless playback:** Preload infrastructure and next-track preloading for seamless transitions +- **Infinite scroll:** Library artists, albums, and tracks now use infinite query pagination +- **CachedImage:** Migrated to Next.js Image component with proper type support + +### Fixed + +- **CSS hover performance:** Fixed hover state performance issues +- **Audio analyzer:** Fixed Enhanced mode detection +- **Onboarding:** Accessibility improvements +- **Audio format detection:** Simplified to prevent wrong decoder attempts +- **Audio cleanup:** Improved Howl instance cleanup to prevent memory leaks +- **Audio cleanup tracking:** Use Set for pending cleanup tracking +- **Redis connections:** Disconnect enrichmentStateService connections on shutdown + +### Changed + +- **Library page:** Optimized data fetching with tab-based queries and memoized delete handlers + ## [1.3.3] - 2026-01-18 Comprehensive patch release addressing critical stability issues, performance improvements, and production readiness fixes. This release includes community-contributed fixes and extensive internal code quality improvements. diff --git a/Dockerfile b/Dockerfile index ba1e45f..094d1fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -183,7 +183,7 @@ RUN npm ci && npm cache clean --force COPY frontend/ ./ # Build Next.js (production) -ENV NEXT_PUBLIC_API_URL= +ENV NEXT_PUBLIC_BACKEND_URL=http://127.0.0.1:3006 RUN npm run build # ============================================ diff --git a/backend/package-lock.json b/backend/package-lock.json index 09712d3..35daeed 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "lidify-backend", - "version": "1.3.7", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lidify-backend", - "version": "1.3.7", + "version": "1.4.0", "license": "GPL-3.0", "dependencies": { "@bull-board/api": "^6.14.2", diff --git a/backend/package.json b/backend/package.json index 71c6f95..a845a2f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "lidify-backend", - "version": "1.3.8", + "version": "1.4.0", "description": "Lidify backend API server", "license": "GPL-3.0", "repository": { diff --git a/backend/src/routes/browse.ts b/backend/src/routes/browse.ts index dd87fb7..ba4f82e 100644 --- a/backend/src/routes/browse.ts +++ b/backend/src/routes/browse.ts @@ -58,10 +58,6 @@ function deezerRadioToUnified(radio: DeezerRadioStation): PlaylistPreview { }; } -// ============================================ -// Playlist Endpoints -// ============================================ - /** * GET /api/browse/playlists/featured * Get featured/chart playlists from Deezer @@ -138,10 +134,6 @@ router.get("/playlists/:id", async (req, res) => { } }); -// ============================================ -// Radio Endpoints -// ============================================ - /** * GET /api/browse/radios * Get all radio stations (mood/theme based mixes) @@ -215,10 +207,6 @@ router.get("/radios/:id", async (req, res) => { } }); -// ============================================ -// Genre Endpoints -// ============================================ - /** * GET /api/browse/genres * Get all available genres @@ -296,10 +284,6 @@ router.get("/genres/:id/playlists", async (req, res) => { } }); -// ============================================ -// URL Parsing (supports both Spotify & Deezer) -// ============================================ - /** * POST /api/browse/playlists/parse * Parse a Spotify or Deezer URL and return playlist info @@ -343,10 +327,6 @@ router.post("/playlists/parse", async (req, res) => { } }); -// ============================================ -// Combined Browse Endpoint (for frontend convenience) -// ============================================ - /** * GET /api/browse/all * Get a combined view of featured content (playlists, genres) diff --git a/backend/src/routes/library.ts b/backend/src/routes/library.ts index de0d525..53c3c32 100644 --- a/backend/src/routes/library.ts +++ b/backend/src/routes/library.ts @@ -942,9 +942,8 @@ router.get("/artists/:id", async (req, res) => { return res.status(404).json({ error: "Artist not found" }); } - // ========== DISCOGRAPHY HANDLING ========== - // For enriched artists with ownedAlbums, skip expensive MusicBrainz calls - // Only fetch from MusicBrainz if the artist hasn't been enriched yet + // For enriched artists with ownedAlbums, skip expensive MusicBrainz calls. + // Only fetch from MusicBrainz if the artist hasn't been enriched yet. let albumsWithOwnership = []; const ownedRgMbids = new Set(artist.ownedAlbums.map((o) => o.rgMbid)); const isEnriched = @@ -994,8 +993,7 @@ router.get("/artists/:id", async (req, res) => { } } - // ========== ALWAYS include albums from database (actual owned files) ========== - // These are albums with actual tracks on disk - they MUST show as owned + // Albums from database have actual tracks on disk - they MUST show as owned const dbAlbums = artist.albums.map((album) => ({ ...album, owned: true, // If it's in the database with tracks, user owns it! @@ -1007,7 +1005,6 @@ router.get("/artists/:id", async (req, res) => { `[Artist] Found ${dbAlbums.length} albums from database (actual owned files)` ); - // ========== Supplement with MusicBrainz discography for "available to download" ========== // Always fetch discography if we have a valid MBID - users need to see what's available const hasDbAlbums = dbAlbums.length > 0; const shouldFetchDiscography = @@ -1286,15 +1283,12 @@ router.get("/artists/:id", async (req, res) => { })); } - // ========== HERO IMAGE FETCHING ========== - // Use DataCacheService: DB -> Redis -> API -> save to both const heroUrl = await dataCacheService.getArtistImage( artist.id, artist.name, effectiveMbid ); - // ========== SIMILAR ARTISTS (from enriched JSON or Last.fm API) ========== let similarArtists: any[] = []; const similarCacheKey = `similar-artists:${artist.id}`; diff --git a/backend/src/routes/mixes.ts b/backend/src/routes/mixes.ts index 017eebc..68fb31d 100644 --- a/backend/src/routes/mixes.ts +++ b/backend/src/routes/mixes.ts @@ -440,10 +440,6 @@ router.post("/mood/save-preferences", async (req, res) => { } }); -// ============================================ -// NEW SIMPLIFIED MOOD BUCKET ENDPOINTS -// ============================================ - /** * @openapi * /mixes/mood/buckets/presets: diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index 931d614..e201167 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -132,10 +132,6 @@ router.post( } ); -// ============================================ -// Download History Endpoints -// ============================================ - /** * GET /notifications/downloads/history * Get completed/failed downloads that haven't been cleared diff --git a/backend/src/routes/playlists.ts b/backend/src/routes/playlists.ts index e171fa3..8a9bbe7 100644 --- a/backend/src/routes/playlists.ts +++ b/backend/src/routes/playlists.ts @@ -537,10 +537,6 @@ router.put("/:id/items/reorder", async (req, res) => { } }); -// ============================================ -// Pending Tracks (from Spotify imports) -// ============================================ - /** * GET /playlists/:id/pending * Get pending tracks for a playlist (tracks from Spotify that haven't been matched yet) diff --git a/backend/src/routes/recommendations.ts b/backend/src/routes/recommendations.ts index 8c67c47..d7b0e6d 100644 --- a/backend/src/routes/recommendations.ts +++ b/backend/src/routes/recommendations.ts @@ -115,9 +115,8 @@ router.get("/for-you", async (req, res) => { albumCounts.map((ac) => [ac.artistId, ac._count.rgMbid]) ); - // ========== CACHE-ONLY IMAGE LOOKUP FOR RECOMMENDATIONS ========== - // Only use cached data (DB heroUrl or Redis cache) - no API calls during page loads - // Background enrichment worker will populate cache over time + // Only use cached data (DB heroUrl or Redis cache) - no API calls during page loads. + // Background enrichment worker will populate cache over time. const { redisClient } = await import("../utils/redis"); // Get all cached images in a single Redis call for efficiency diff --git a/backend/src/services/acquisitionService.ts b/backend/src/services/acquisitionService.ts index 521f36d..a2d6eab 100644 --- a/backend/src/services/acquisitionService.ts +++ b/backend/src/services/acquisitionService.ts @@ -21,10 +21,6 @@ import { lastFmService } from "./lastfm"; import { AcquisitionError, AcquisitionErrorType } from "./lidarr"; import PQueue from "p-queue"; -// ============================================ -// TYPE DEFINITIONS -// ============================================ - /** * Context for tracking acquisition origin * Used to link download jobs to their source (Discovery batch or Spotify import) @@ -89,10 +85,6 @@ interface DownloadBehavior { fallbackSource: "soulseek" | "lidarr" | null; } -// ============================================ -// ACQUISITION SERVICE -// ============================================ - class AcquisitionService { private albumQueue: PQueue; private lastConcurrency: number = 4; diff --git a/backend/src/services/audiobookCache.ts b/backend/src/services/audiobookCache.ts index 1478378..88e36b6 100644 --- a/backend/src/services/audiobookCache.ts +++ b/backend/src/services/audiobookCache.ts @@ -118,18 +118,14 @@ export class AudiobookCacheService { * Sync a single audiobook */ private async syncAudiobook(book: any): Promise { - // Extract metadata from Audiobookshelf API response structure - // The API returns: { id, media: { metadata: { title, author, ... } } } const metadata = book.media?.metadata || book; const title = metadata.title || book.title; - // Skip if no title (invalid audiobook data) if (!title) { logger.warn(` Skipping audiobook ${book.id} - missing title`); return; } - // Extract additional fields from API response const author = metadata.authorName || metadata.author || null; const narrator = metadata.narratorName || metadata.narrator || null; const description = metadata.description || null; @@ -148,22 +144,16 @@ export class AudiobookCacheService { const size = book.size ? BigInt(book.size) : null; const libraryId = book.libraryId || null; - // Get cover path - Audiobookshelf uses media.coverPath const coverPath = book.media?.coverPath || null; - - // Build full cover URL for download (needs to be absolute URL with base) const coverUrl = coverPath ? `items/${book.id}/cover` : null; - // Series info - Audiobookshelf returns seriesName as a string like "Series Name #2" - // We need to parse this to extract the series name and sequence number + // Parse series name and sequence from seriesName string (e.g. "Series Name #2") let series: string | null = null; let seriesSequence: string | null = null; if (metadata.seriesName && typeof metadata.seriesName === "string") { const seriesStr = metadata.seriesName.trim(); - // Try to extract sequence from patterns like: - // "Series Name #1", "Series Name #2", "Series Name Book 1", "Series Name, Book 1" const sequencePatterns = [ /^(.+?)\s*#(\d+(?:\.\d+)?)\s*$/, // "Series Name #1" or "Series Name #1.5" /^(.+?)\s*,?\s*Book\s*(\d+(?:\.\d+)?)\s*$/i, // "Series Name Book 1" or "Series Name, Book 1" @@ -189,7 +179,6 @@ export class AudiobookCacheService { } } - // Fallback: check metadata.series array/object format if (!series) { if (Array.isArray(metadata.series) && metadata.series.length > 0) { series = metadata.series[0]?.name || null; @@ -204,7 +193,6 @@ export class AudiobookCacheService { } } - // Log series info for debugging (only for first few books) if (series) { logger.debug( ` [Series] "${title}" -> "${series}" #${ @@ -213,10 +201,8 @@ export class AudiobookCacheService { ); } - // Download cover image if available - need to construct full URL let localCoverPath: string | null = null; if (coverUrl) { - // Get the Audiobookshelf base URL from the service const fullCoverUrl = await this.getFullCoverUrl(coverUrl); if (fullCoverUrl) { localCoverPath = await this.downloadCover( diff --git a/backend/src/services/deezer.ts b/backend/src/services/deezer.ts index 374a9e2..be69cf6 100644 --- a/backend/src/services/deezer.ts +++ b/backend/src/services/deezer.ts @@ -11,10 +11,6 @@ import { redisClient } from "../utils/redis"; const DEEZER_API = "https://api.deezer.com"; -// ============================================ -// Playlist Types -// ============================================ - export interface DeezerTrack { deezerId: string; title: string; @@ -68,10 +64,6 @@ export interface DeezerGenreWithRadios { radios: DeezerRadioStation[]; } -// ============================================ -// Service Class -// ============================================ - class DeezerService { private readonly cachePrefix = "deezer:"; private readonly cacheTTL = 86400; // 24 hours @@ -98,10 +90,6 @@ class DeezerService { } } - // ============================================ - // Image & Preview Methods (existing functionality) - // ============================================ - /** * Search for an artist and get their image URL */ @@ -250,10 +238,6 @@ class DeezerService { } } - // ============================================ - // Playlist Methods (new functionality) - // ============================================ - /** * Parse a Deezer URL and extract the type and ID */ @@ -477,10 +461,6 @@ class DeezerService { return this.searchPlaylists(genreName, limit); } - // ============================================ - // Radio Methods - // ============================================ - /** * Get all radio stations (mood/theme based mixes) * Cached for 24 hours diff --git a/backend/src/services/discoverWeekly.ts b/backend/src/services/discoverWeekly.ts index 04ed654..82008e5 100644 --- a/backend/src/services/discoverWeekly.ts +++ b/backend/src/services/discoverWeekly.ts @@ -938,12 +938,6 @@ export class DiscoverWeeklyService { return; } - // ============================================== - // PLAYLIST COMPOSITION: ALL Discovery + ~20% Anchors - // ONE TRACK PER ALBUM - Each album contributes only 1 track - // Include ALL successfully downloaded albums! - // ============================================== - // Group tracks by album ID and pick ONE random track per album const tracksByAlbum = new Map(); for (const track of allTracks) { @@ -2298,9 +2292,6 @@ export class DiscoverWeeklyService { ` Total similar artists from all seeds: ${allSimilarArtists.length}` ); - // ============================================ - // PASS 1: NEW ARTISTS ONLY (true discovery) - // ============================================ logger.debug(`\n === PASS 1: NEW Artists Only ===`); for (const sim of allSimilarArtists) { @@ -2356,9 +2347,6 @@ export class DiscoverWeeklyService { ` Pass 1 complete: ${recommendations.length}/${targetCount} from NEW artists` ); - // ============================================ - // PASS 2: EXISTING ARTISTS (fallback if needed) - // ============================================ if ( recommendations.length < targetCount && existingArtistsForFallback.length > 0 @@ -2609,11 +2597,6 @@ export class DiscoverWeeklyService { }; } - // ============================================ - // MULTI-STRATEGY DISCOVERY ENGINE - // Rotates weekly to keep recommendations fresh - // ============================================ - /** * Get user's top genres from listening history */ diff --git a/backend/src/services/featureDetection.ts b/backend/src/services/featureDetection.ts index 69a9cf9..276994b 100644 --- a/backend/src/services/featureDetection.ts +++ b/backend/src/services/featureDetection.ts @@ -1,7 +1,12 @@ +import { existsSync } from "fs"; import { redisClient } from "../utils/redis"; import { prisma } from "../utils/db"; import { logger } from "../utils/logger"; +// Analyzer script paths in the Docker image +const ESSENTIA_ANALYZER_PATH = "/app/audio-analyzer/analyzer.py"; +const CLAP_ANALYZER_PATH = "/app/audio-analyzer-clap/analyzer.py"; + export interface AvailableFeatures { musicCNN: boolean; vibeEmbeddings: boolean; @@ -37,6 +42,11 @@ class FeatureDetectionService { private async checkMusicCNN(): Promise { try { + // Analyzer script bundled in image = feature is available + if (existsSync(ESSENTIA_ANALYZER_PATH)) { + return true; + } + const heartbeat = await redisClient.get("audio:worker:heartbeat"); if (heartbeat) { const timestamp = parseInt(heartbeat, 10); @@ -58,6 +68,11 @@ class FeatureDetectionService { private async checkCLAP(): Promise { try { + // Analyzer script bundled in image = feature is available + if (existsSync(CLAP_ANALYZER_PATH)) { + return true; + } + const heartbeat = await redisClient.get("clap:worker:heartbeat"); if (heartbeat) { const timestamp = parseInt(heartbeat, 10); diff --git a/backend/src/services/lidarr.ts b/backend/src/services/lidarr.ts index 28d6949..b4be7b7 100644 --- a/backend/src/services/lidarr.ts +++ b/backend/src/services/lidarr.ts @@ -4,10 +4,6 @@ import { config } from "../config"; import { getSystemSettings } from "../utils/systemSettings"; import { stripAlbumEdition } from "../utils/artistNormalization"; -// ============================================ -// STRUCTURED ERROR TYPES -// ============================================ - /** * Error types for music acquisition failures * Used to determine fallback strategies @@ -1162,9 +1158,6 @@ class LidarrService { // Use the verified album data const updatedAlbum = verifyResponse.data; - // ============================================================ - // PHASE 2.1: Proactive anyReleaseOk for edition variants - // ============================================================ const editionPatterns = [ /\(remaster/i, /\(deluxe/i, @@ -1264,9 +1257,6 @@ class LidarrService { ); if (result.message?.includes("0 reports")) { - // ============================================================ - // PHASE 2.3: Enhanced diagnostics for 0 reports - // ============================================================ try { const albumDetails = await this.client.get( `/api/v1/album/${updatedAlbum.id}` @@ -1351,9 +1341,6 @@ class LidarrService { ); if (retryResult.message?.includes("0 reports")) { - // ============================================================ - // PHASE 2.2: Fallback to base album title - // ============================================================ const baseAlbumTitle = this.extractBaseTitle(albumTitle); if (baseAlbumTitle !== albumTitle && baseAlbumTitle.length > 2) { @@ -1797,10 +1784,6 @@ class LidarrService { } } - // ============================================ - // Tag Management Methods (for discovery tracking) - // ============================================ - /** * Get all tags from Lidarr */ @@ -2097,10 +2080,6 @@ class LidarrService { } } - // ============================================ - // Release Iteration Methods (for exhaustive retry) - // ============================================ - /** * Get all available releases for an album from all indexers * This is what Lidarr's "Interactive Search" uses @@ -2349,10 +2328,6 @@ class LidarrService { } } - // ============================================ - // BATCH RECONCILIATION METHODS - // ============================================ - /** * Fetch all data needed for reconciliation in minimal API calls. * Returns indexed Maps for O(1) lookups against job data. @@ -2558,10 +2533,6 @@ export interface LidarrRelease { export const lidarrService = new LidarrService(); -// ============================================ -// Queue Cleaner Functions -// ============================================ - // Types for queue monitoring interface QueueItem { id: number; diff --git a/backend/src/services/programmaticPlaylists.ts b/backend/src/services/programmaticPlaylists.ts index b2b91df..73b2402 100644 --- a/backend/src/services/programmaticPlaylists.ts +++ b/backend/src/services/programmaticPlaylists.ts @@ -1513,9 +1513,7 @@ export class ProgrammaticPlaylistService { }; } - // ============================================================ // AUDIO ANALYSIS-BASED MIXES (Using Essentia features) - // ============================================================ /** * Generate "High Energy" mix using audio analysis @@ -2200,9 +2198,7 @@ export class ProgrammaticPlaylistService { }; } - // ============================================================ // LAST.FM TAG-BASED MIXES - // ============================================================ /** * Generate mix based on Last.fm mood tags @@ -2361,9 +2357,7 @@ export class ProgrammaticPlaylistService { }; } - // ============================================================ // DAY-OF-WEEK MIXES - // ============================================================ /** * Generate day-specific mix based on the current day @@ -2531,10 +2525,7 @@ export class ProgrammaticPlaylistService { }; } - // ============================================================ // CURATED VIBE MIXES (Daily, 10 tracks) - // These are "mood" mixes based on audio analysis and vibes - // ============================================================ /** * "Sad Girl Sundays" - Melancholic introspection @@ -3253,9 +3244,7 @@ export class ProgrammaticPlaylistService { }; } - // ============================================================ // WEEKLY CURATED MIXES (20 tracks) - // ============================================================ /** * "Deep Cuts" - Hidden gems from your library @@ -3574,9 +3563,7 @@ export class ProgrammaticPlaylistService { }; } - // ============================================================ - // MOOD ON DEMAND - Generate a mix based on specific criteria - // ============================================================ + // MOOD ON DEMAND /** * Generate a custom mood mix based on audio feature parameters diff --git a/backend/src/workers/artistEnrichment.ts b/backend/src/workers/artistEnrichment.ts index be2c043..3e3ab7b 100644 --- a/backend/src/workers/artistEnrichment.ts +++ b/backend/src/workers/artistEnrichment.ts @@ -373,7 +373,6 @@ export async function enrichSimilarArtist(artist: Artist): Promise { ); } - // ========== ALBUM COVER ENRICHMENT ========== // Fetch covers for all albums belonging to this artist that don't have covers yet await enrichAlbumCovers(artist.id, localHeroUrl); diff --git a/backend/src/workers/unifiedEnrichment.ts b/backend/src/workers/unifiedEnrichment.ts index 66f57a7..09e889a 100644 --- a/backend/src/workers/unifiedEnrichment.ts +++ b/backend/src/workers/unifiedEnrichment.ts @@ -29,7 +29,7 @@ import pLimit from "p-limit"; // Configuration const ARTIST_BATCH_SIZE = 10; const TRACK_BATCH_SIZE = 20; -const ENRICHMENT_INTERVAL_MS = 30 * 1000; // 30 seconds +const ENRICHMENT_INTERVAL_MS = 5 * 1000; // 5 seconds - rate limiter handles API limits const MAX_CONSECUTIVE_SYSTEM_FAILURES = 5; // Circuit breaker threshold let isRunning = false; @@ -55,6 +55,9 @@ let currentBatchFailures: BatchFailures = { audio: [], }; +// Session-level failure counter (accumulates across cycles, reset on enrichment start) +let sessionFailureCount = { artists: 0, tracks: 0, audio: 0 }; + // Mood tags to extract from Last.fm const MOOD_TAGS = new Set([ // Energy/Activity @@ -215,8 +218,17 @@ export async function startUnifiedEnrichmentWorker() { logger.debug(` Interval: ${ENRICHMENT_INTERVAL_MS / 1000}s`); logger.debug(""); - // Initialize state - await enrichmentStateService.initializeState(); + // Check if there's existing state that might be problematic + const existingState = await enrichmentStateService.getState(); + + // Only clear state if it exists and is in a non-idle state + // This prevents clearing fresh state from a previous worker instance + if (existingState && existingState.status !== "idle") { + await enrichmentStateService.clear(); + } + + // Initialize state + await enrichmentStateService.initializeState(); // Setup control channel subscription await setupControlChannel(); @@ -377,88 +389,68 @@ async function runEnrichmentCycle(fullMode: boolean): Promise<{ tracks: number; audioQueued: number; }> { - // Check if paused + const emptyResult = { artists: 0, tracks: 0, audioQueued: 0 }; + + // Sync local pause flag with state service + if (!isPaused) { + const state = await enrichmentStateService.getState(); + if (state?.status === "paused" || state?.status === "stopping") { + isPaused = true; + } + } + if (isPaused) { - return { artists: 0, tracks: 0, audioQueued: 0 }; + return emptyResult; } - // Check state service - const state = await enrichmentStateService.getState(); - if (state?.status === "paused" || state?.status === "stopping") { - isPaused = true; - return { artists: 0, tracks: 0, audioQueued: 0 }; + // Skip if already running (unless full mode or immediate request) + const bypassRunningCheck = fullMode || immediateEnrichmentRequested; + if (isRunning && !bypassRunningCheck) { + return emptyResult; } - // Allow immediate enrichment requests to bypass the isRunning check - // This prevents race conditions when new content is imported - if (isRunning && !fullMode && !immediateEnrichmentRequested) { - return { artists: 0, tracks: 0, audioQueued: 0 }; - } - - // Enforce minimum interval between cycles (unless full mode or immediate request) + // Enforce minimum interval (unless full mode or immediate request) const now = Date.now(); - if ( - !fullMode && - !immediateEnrichmentRequested && - now - lastRunTime < MIN_INTERVAL_MS - ) { - return { artists: 0, tracks: 0, audioQueued: 0 }; + if (!bypassRunningCheck && now - lastRunTime < MIN_INTERVAL_MS) { + return emptyResult; } - // Clear the immediate request flag immediateEnrichmentRequested = false; lastRunTime = now; - isRunning = true; + let artistsProcessed = 0; let tracksProcessed = 0; let audioQueued = 0; -try { - // Reset system failure counter on successful cycle start - consecutiveSystemFailures = 0; + try { + consecutiveSystemFailures = 0; - const artistsPhase = await enrichArtistsPhase(); - if (!artistsPhase.shouldContinue) { - return { - artists: artistsPhase.result, - tracks: 0, - audioQueued: 0, - }; - } - artistsProcessed = artistsPhase.result; + // Run phases sequentially, halting if stopped/paused + const artistResult = await runPhase("artists", executeArtistsPhase); + if (artistResult === null) { + return { artists: 0, tracks: 0, audioQueued: 0 }; + } + artistsProcessed = artistResult; - const moodTagsPhase = await enrichMoodTagsPhase(); - if (!moodTagsPhase.shouldContinue) { - return { - artists: artistsProcessed, - tracks: moodTagsPhase.result, - audioQueued: 0, - }; - } - tracksProcessed = moodTagsPhase.result; + const trackResult = await runPhase("tracks", executeMoodTagsPhase); + if (trackResult === null) { + return { artists: artistsProcessed, tracks: 0, audioQueued: 0 }; + } + tracksProcessed = trackResult; - const audioPhase = await enrichAudioPhase(); - if (!audioPhase.shouldContinue) { - return { - artists: artistsProcessed, - tracks: tracksProcessed, - audioQueued: audioPhase.result, - }; - } - audioQueued = audioPhase.result; + const audioResult = await runPhase("audio", executeAudioPhase); + if (audioResult === null) { + return { artists: artistsProcessed, tracks: tracksProcessed, audioQueued: 0 }; + } + audioQueued = audioResult; - const vibePhase = await enrichVibePhase(); - if (!vibePhase.shouldContinue) { - return { - artists: artistsProcessed, - tracks: tracksProcessed, - audioQueued: audioQueued, - }; - } - - const features = await featureDetection.getFeatures(); - const vibeQueued = vibePhase.result; + const vibeResult = await runPhase("vibe", executeVibePhase); + if (vibeResult === null) { + return { artists: artistsProcessed, tracks: tracksProcessed, audioQueued }; + } + const vibeQueued = vibeResult; + const features = await featureDetection.getFeatures(); // Log progress (only if work was done) if (artistsProcessed > 0 || tracksProcessed > 0 || audioQueued > 0 || vibeQueued > 0) { @@ -500,51 +492,18 @@ try { }, completionNotificationSent: false, // Reset flag when new work is processed }); + + // Reset session failure counter when new work begins + sessionFailureCount = { artists: 0, tracks: 0, audio: 0 }; } - // Send failure notification if there were any failures in this batch - const totalFailures = - currentBatchFailures.artists.length + - currentBatchFailures.tracks.length + - currentBatchFailures.audio.length; + // Accumulate cycle failures into session counter before resetting + sessionFailureCount.artists += currentBatchFailures.artists.length; + sessionFailureCount.tracks += currentBatchFailures.tracks.length; + sessionFailureCount.audio += currentBatchFailures.audio.length; - if (totalFailures > 0) { - try { - const failureCounts = - await enrichmentFailureService.getFailureCounts(); - - const { notificationService } = - await import("../services/notificationService"); - const users = await prisma.user.findMany({ - select: { id: true }, - }); - for (const user of users) { - await notificationService.create({ - userId: user.id, - type: "error", - title: "Enrichment Completed with Errors", - message: `${failureCounts.total} items failed enrichment. Click to view and retry.`, - metadata: { - actionUrl: "/settings#enrichment-failures", - actionLabel: "View Failures", - failureCounts, - }, - }); - } - - logger.debug( - `[Enrichment] Failure notification sent: ${totalFailures} failures in batch`, - ); - } catch (error) { - logger.error( - "[Enrichment] Failed to send failure notification:", - error, - ); - } - - // Reset batch failures - currentBatchFailures = { artists: [], tracks: [], audio: [] }; - } + // Reset batch failures (failures are viewable in Settings > Enrichment) + currentBatchFailures = { artists: [], tracks: [], audio: [] }; // If everything is complete, mark as idle and send notification (only once) const progress = await getEnrichmentProgress(); @@ -613,7 +572,26 @@ try { const users = await prisma.user.findMany({ select: { id: true }, }); + const totalSessionFailures = + sessionFailureCount.artists + + sessionFailureCount.tracks + + sessionFailureCount.audio; + for (const user of users) { + if (totalSessionFailures > 0) { + const parts: string[] = []; + if (sessionFailureCount.artists > 0) parts.push(`${sessionFailureCount.artists} artist(s)`); + if (sessionFailureCount.tracks > 0) parts.push(`${sessionFailureCount.tracks} track(s)`); + if (sessionFailureCount.audio > 0) parts.push(`${sessionFailureCount.audio} audio analysis`); + + await notificationService.create({ + userId: user.id, + type: "error", + title: "Enrichment Completed with Errors", + message: `${totalSessionFailures} failures: ${parts.join(", ")}. Check Settings > Enrichment for details.`, + }); + } + await notificationService.notifySystem( user.id, "Enrichment Complete", @@ -1035,63 +1013,52 @@ async function queueVibeEmbeddings(): Promise { return queued; } -interface EnrichmentPhaseResult { - result: number; - shouldContinue: boolean; +/** + * Check if enrichment should stop and handle state cleanup if stopping. + * Returns true if cycle should halt (either stopping or paused). + */ +async function shouldHaltCycle(): Promise { + if (isStopping) { + await enrichmentStateService.updateState({ + status: "idle", + currentPhase: null, + }); + isStopping = false; + return true; + } + return isPaused; } -async function enrichArtistsPhase(): Promise { +/** + * Run a phase and return result. Returns null if cycle should halt. + */ +async function runPhase( + phaseName: "artists" | "tracks" | "audio" | "vibe", + executor: () => Promise, +): Promise { await enrichmentStateService.updateState({ status: "running", - currentPhase: "artists", + currentPhase: phaseName, }); - const result = await enrichArtistsBatch(); + const result = await executor(); - if (isStopping) { - await enrichmentStateService.updateState({ - status: "idle", - currentPhase: null, - }); - isStopping = false; - return { result, shouldContinue: false }; + if (await shouldHaltCycle()) { + return null; } - if (isPaused) { - return { result, shouldContinue: false }; - } - - return { result, shouldContinue: true }; + return result; } -async function enrichMoodTagsPhase(): Promise { - await enrichmentStateService.updateState({ - currentPhase: "tracks", - }); - - const result = await enrichTrackTagsBatch(); - - if (isStopping) { - await enrichmentStateService.updateState({ - status: "idle", - currentPhase: null, - }); - isStopping = false; - return { result, shouldContinue: false }; - } - - if (isPaused) { - return { result, shouldContinue: false }; - } - - return { result, shouldContinue: true }; +async function executeArtistsPhase(): Promise { + return enrichArtistsBatch(); } -async function enrichAudioPhase(): Promise { - await enrichmentStateService.updateState({ - currentPhase: "audio", - }); +async function executeMoodTagsPhase(): Promise { + return enrichTrackTagsBatch(); +} +async function executeAudioPhase(): Promise { const audioCompletedBefore = await prisma.track.count({ where: { analysisStatus: "completed" }, }); @@ -1111,40 +1078,32 @@ async function enrichAudioPhase(): Promise { audioAnalysisCleanupService.recordSuccess(); } - let result = 0; if (audioAnalysisCleanupService.isCircuitOpen()) { logger.warn( "[Enrichment] Audio analysis circuit breaker OPEN - skipping queue", ); - } else { - result = await queueAudioAnalysis(); + return 0; } - if (isStopping) { - await enrichmentStateService.updateState({ - status: "idle", - currentPhase: null, - }); - isStopping = false; - return { result, shouldContinue: false }; - } - - if (isPaused) { - return { result, shouldContinue: false }; - } - - return { result, shouldContinue: true }; + return queueAudioAnalysis(); } -async function enrichVibePhase(): Promise { +async function executeVibePhase(): Promise { const features = await featureDetection.getFeatures(); if (!features.vibeEmbeddings) { - return { result: 0, shouldContinue: true }; + return 0; } - await enrichmentStateService.updateState({ - currentPhase: "vibe", + const audioProcessing = await prisma.track.count({ + where: { analysisStatus: "processing" }, }); + const audioQueue = await getRedis().llen("audio:analysis:queue"); + if (audioProcessing > 0 || audioQueue > 0) { + logger.debug( + `[Enrichment] Skipping vibe phase - audio still running (${audioProcessing} processing, ${audioQueue} queued)`, + ); + return 0; + } const { reset } = await vibeAnalysisCleanupService.cleanupStaleProcessing(); if (reset > 0) { @@ -1153,25 +1112,10 @@ async function enrichVibePhase(): Promise { const result = await queueVibeEmbeddings(); if (result > 0) { - logger.debug( - `[ENRICHMENT] Queued ${result} tracks for vibe embedding` - ); + logger.debug(`[ENRICHMENT] Queued ${result} tracks for vibe embedding`); } - if (isStopping) { - await enrichmentStateService.updateState({ - status: "idle", - currentPhase: null, - }); - isStopping = false; - return { result, shouldContinue: false }; - } - - if (isPaused) { - return { result, shouldContinue: false }; - } - - return { result, shouldContinue: true }; + return result; } /** diff --git a/docs/plans/2026-02-02-vibe-search-accuracy.md b/docs/plans/2026-02-02-vibe-search-accuracy.md deleted file mode 100644 index f162420..0000000 --- a/docs/plans/2026-02-02-vibe-search-accuracy.md +++ /dev/null @@ -1,1178 +0,0 @@ -# Enhanced Vibe Search Accuracy Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Improve vibe search accuracy by adding vocabulary-based query expansion and audio feature re-ranking, so genre searches like "electronic" return genre-appropriate results instead of acoustically-similar but genre-mismatched tracks. - -**Architecture:** Pre-compute CLAP text embeddings for ~150 genre/mood/vibe terms with associated audio feature profiles. At search time, expand user queries by finding similar vocabulary terms, then re-rank CLAP results using audio features with dynamic weighting based on genre confidence. - -**Tech Stack:** TypeScript, CLAP embeddings (existing), PostgreSQL audio features (existing), Redis pub/sub (existing) - ---- - -## Task 1: Create Feature Profile Research Data - -**Files:** -- Create: `backend/src/data/featureProfiles.ts` - -**Step 1: Create the feature profiles module** - -```typescript -// backend/src/data/featureProfiles.ts - -/** - * Research-based audio feature profiles for genres, moods, and vibes. - * Values are target ranges (0-1) based on academic literature on music information retrieval. - * - * Sources: - * - Tzanetakis & Cook (2002) - Musical genre classification - * - Laurier et al. (2008) - Audio music mood classification - * - Spotify Audio Features documentation - */ - -export interface FeatureProfile { - energy?: number; - valence?: number; - danceability?: number; - acousticness?: number; - instrumentalness?: number; - arousal?: number; - speechiness?: number; -} - -export type TermType = "genre" | "mood" | "vibe" | "descriptor"; - -export interface VocabTermDefinition { - type: TermType; - featureProfile: FeatureProfile; - related?: string[]; -} - -export const VOCAB_DEFINITIONS: Record = { - // === GENRES === - electronic: { - type: "genre", - featureProfile: { instrumentalness: 0.7, acousticness: 0.15, danceability: 0.7, energy: 0.65 }, - related: ["synth", "edm", "techno", "house", "trance"] - }, - techno: { - type: "genre", - featureProfile: { instrumentalness: 0.85, acousticness: 0.1, danceability: 0.8, energy: 0.75 }, - related: ["electronic", "house", "minimal"] - }, - house: { - type: "genre", - featureProfile: { instrumentalness: 0.6, acousticness: 0.1, danceability: 0.85, energy: 0.7 }, - related: ["electronic", "disco", "dance"] - }, - trance: { - type: "genre", - featureProfile: { instrumentalness: 0.8, acousticness: 0.1, danceability: 0.75, energy: 0.7, arousal: 0.65 }, - related: ["electronic", "edm"] - }, - ambient: { - type: "genre", - featureProfile: { instrumentalness: 0.9, acousticness: 0.4, energy: 0.2, arousal: 0.2, danceability: 0.15 }, - related: ["electronic", "atmospheric", "chill"] - }, - trap: { - type: "genre", - featureProfile: { instrumentalness: 0.3, acousticness: 0.1, danceability: 0.7, energy: 0.7 }, - related: ["hip-hop", "rap", "electronic"] - }, - "hip-hop": { - type: "genre", - featureProfile: { instrumentalness: 0.2, acousticness: 0.15, danceability: 0.75, speechiness: 0.3 }, - related: ["rap", "trap", "r&b"] - }, - rock: { - type: "genre", - featureProfile: { instrumentalness: 0.3, acousticness: 0.25, energy: 0.75, danceability: 0.5 }, - related: ["alternative", "indie", "punk"] - }, - metal: { - type: "genre", - featureProfile: { instrumentalness: 0.4, acousticness: 0.05, energy: 0.95, arousal: 0.9, valence: 0.3 }, - related: ["heavy", "hard rock"] - }, - punk: { - type: "genre", - featureProfile: { instrumentalness: 0.2, acousticness: 0.2, energy: 0.9, danceability: 0.5, valence: 0.5 }, - related: ["rock", "alternative"] - }, - jazz: { - type: "genre", - featureProfile: { instrumentalness: 0.6, acousticness: 0.7, danceability: 0.5, energy: 0.4 }, - related: ["blues", "soul", "swing"] - }, - blues: { - type: "genre", - featureProfile: { instrumentalness: 0.4, acousticness: 0.65, valence: 0.35, energy: 0.45 }, - related: ["jazz", "soul", "rock"] - }, - classical: { - type: "genre", - featureProfile: { instrumentalness: 0.95, acousticness: 0.9, speechiness: 0.05, danceability: 0.25 }, - related: ["orchestral", "piano", "instrumental"] - }, - folk: { - type: "genre", - featureProfile: { instrumentalness: 0.3, acousticness: 0.85, energy: 0.35, danceability: 0.4 }, - related: ["acoustic", "country", "indie"] - }, - country: { - type: "genre", - featureProfile: { instrumentalness: 0.25, acousticness: 0.6, valence: 0.6, danceability: 0.55 }, - related: ["folk", "americana"] - }, - "r&b": { - type: "genre", - featureProfile: { instrumentalness: 0.2, acousticness: 0.3, danceability: 0.7, valence: 0.55 }, - related: ["soul", "hip-hop", "funk"] - }, - soul: { - type: "genre", - featureProfile: { instrumentalness: 0.25, acousticness: 0.45, valence: 0.5, energy: 0.5 }, - related: ["r&b", "funk", "gospel"] - }, - funk: { - type: "genre", - featureProfile: { instrumentalness: 0.35, acousticness: 0.3, danceability: 0.85, energy: 0.7 }, - related: ["soul", "disco", "groove"] - }, - disco: { - type: "genre", - featureProfile: { instrumentalness: 0.3, acousticness: 0.2, danceability: 0.9, energy: 0.75, valence: 0.8 }, - related: ["funk", "house", "dance"] - }, - pop: { - type: "genre", - featureProfile: { instrumentalness: 0.15, acousticness: 0.3, danceability: 0.7, valence: 0.65 }, - related: ["dance", "synth"] - }, - indie: { - type: "genre", - featureProfile: { instrumentalness: 0.35, acousticness: 0.5, energy: 0.55, danceability: 0.5 }, - related: ["alternative", "rock", "folk"] - }, - alternative: { - type: "genre", - featureProfile: { instrumentalness: 0.3, acousticness: 0.4, energy: 0.6, danceability: 0.5 }, - related: ["indie", "rock"] - }, - reggae: { - type: "genre", - featureProfile: { instrumentalness: 0.3, acousticness: 0.4, danceability: 0.75, valence: 0.65, energy: 0.5 }, - related: ["dub", "ska"] - }, - dubstep: { - type: "genre", - featureProfile: { instrumentalness: 0.6, acousticness: 0.05, energy: 0.85, danceability: 0.65 }, - related: ["electronic", "bass"] - }, - dnb: { - type: "genre", - featureProfile: { instrumentalness: 0.7, acousticness: 0.05, energy: 0.9, danceability: 0.7 }, - related: ["electronic", "jungle", "bass"] - }, - lofi: { - type: "genre", - featureProfile: { instrumentalness: 0.7, acousticness: 0.4, energy: 0.3, arousal: 0.3, danceability: 0.4 }, - related: ["chill", "hip-hop", "ambient"] - }, - - // === MOODS === - happy: { - type: "mood", - featureProfile: { valence: 0.85, energy: 0.7, arousal: 0.6, danceability: 0.7 }, - related: ["upbeat", "cheerful", "joyful"] - }, - sad: { - type: "mood", - featureProfile: { valence: 0.2, energy: 0.3, arousal: 0.3, danceability: 0.3 }, - related: ["melancholic", "somber", "blue"] - }, - melancholic: { - type: "mood", - featureProfile: { valence: 0.25, energy: 0.35, arousal: 0.4, acousticness: 0.5 }, - related: ["sad", "nostalgic", "bittersweet"] - }, - angry: { - type: "mood", - featureProfile: { valence: 0.25, energy: 0.9, arousal: 0.9 }, - related: ["aggressive", "intense", "heavy"] - }, - aggressive: { - type: "mood", - featureProfile: { valence: 0.3, energy: 0.9, arousal: 0.85 }, - related: ["angry", "intense", "heavy"] - }, - peaceful: { - type: "mood", - featureProfile: { valence: 0.6, energy: 0.2, arousal: 0.2, acousticness: 0.6 }, - related: ["calm", "serene", "tranquil"] - }, - calm: { - type: "mood", - featureProfile: { energy: 0.25, arousal: 0.25, valence: 0.55 }, - related: ["peaceful", "relaxed", "serene"] - }, - anxious: { - type: "mood", - featureProfile: { valence: 0.3, arousal: 0.75, energy: 0.6 }, - related: ["tense", "nervous"] - }, - romantic: { - type: "mood", - featureProfile: { valence: 0.6, energy: 0.4, acousticness: 0.5, arousal: 0.45 }, - related: ["love", "intimate", "sensual"] - }, - hopeful: { - type: "mood", - featureProfile: { valence: 0.7, energy: 0.55, arousal: 0.5 }, - related: ["uplifting", "optimistic", "bright"] - }, - nostalgic: { - type: "mood", - featureProfile: { valence: 0.45, energy: 0.4, arousal: 0.4 }, - related: ["melancholic", "bittersweet", "wistful"] - }, - dark: { - type: "mood", - featureProfile: { valence: 0.2, energy: 0.5, acousticness: 0.3, arousal: 0.5 }, - related: ["brooding", "ominous", "moody"] - }, - bright: { - type: "mood", - featureProfile: { valence: 0.8, energy: 0.65, arousal: 0.6 }, - related: ["happy", "cheerful", "sunny"] - }, - - // === VIBES === - chill: { - type: "vibe", - featureProfile: { energy: 0.3, arousal: 0.3, valence: 0.55, danceability: 0.45 }, - related: ["relaxed", "mellow", "laid-back"] - }, - relaxed: { - type: "vibe", - featureProfile: { energy: 0.25, arousal: 0.25, valence: 0.5 }, - related: ["chill", "calm", "peaceful"] - }, - energetic: { - type: "vibe", - featureProfile: { energy: 0.85, arousal: 0.8, danceability: 0.75 }, - related: ["upbeat", "powerful", "driving"] - }, - upbeat: { - type: "vibe", - featureProfile: { energy: 0.75, valence: 0.75, danceability: 0.7 }, - related: ["energetic", "happy", "cheerful"] - }, - groovy: { - type: "vibe", - featureProfile: { danceability: 0.85, energy: 0.65, valence: 0.6 }, - related: ["funky", "rhythmic", "danceable"] - }, - dreamy: { - type: "vibe", - featureProfile: { energy: 0.35, arousal: 0.35, acousticness: 0.5, instrumentalness: 0.5 }, - related: ["ethereal", "atmospheric", "ambient"] - }, - ethereal: { - type: "vibe", - featureProfile: { energy: 0.3, instrumentalness: 0.6, acousticness: 0.45, arousal: 0.35 }, - related: ["dreamy", "atmospheric", "ambient"] - }, - atmospheric: { - type: "vibe", - featureProfile: { instrumentalness: 0.7, energy: 0.4, acousticness: 0.4 }, - related: ["ambient", "ethereal", "cinematic"] - }, - intense: { - type: "vibe", - featureProfile: { energy: 0.85, arousal: 0.85, valence: 0.4 }, - related: ["powerful", "aggressive", "dramatic"] - }, - playful: { - type: "vibe", - featureProfile: { valence: 0.75, energy: 0.65, danceability: 0.7 }, - related: ["fun", "quirky", "whimsical"] - }, - brooding: { - type: "vibe", - featureProfile: { valence: 0.25, energy: 0.45, arousal: 0.5 }, - related: ["dark", "moody", "introspective"] - }, - cinematic: { - type: "vibe", - featureProfile: { instrumentalness: 0.8, energy: 0.5, acousticness: 0.5 }, - related: ["epic", "dramatic", "orchestral"] - }, - epic: { - type: "vibe", - featureProfile: { energy: 0.75, arousal: 0.7, instrumentalness: 0.6 }, - related: ["cinematic", "dramatic", "powerful"] - }, - mellow: { - type: "vibe", - featureProfile: { energy: 0.3, arousal: 0.3, valence: 0.5, acousticness: 0.5 }, - related: ["chill", "relaxed", "soft"] - }, - funky: { - type: "vibe", - featureProfile: { danceability: 0.85, energy: 0.7, valence: 0.65 }, - related: ["groovy", "rhythmic"] - }, - hypnotic: { - type: "vibe", - featureProfile: { instrumentalness: 0.7, danceability: 0.6, energy: 0.5, arousal: 0.5 }, - related: ["trance", "repetitive", "mesmerizing"] - }, - - // === DESCRIPTORS === - fast: { - type: "descriptor", - featureProfile: { energy: 0.8, danceability: 0.7 }, - related: ["energetic", "upbeat"] - }, - slow: { - type: "descriptor", - featureProfile: { energy: 0.3, danceability: 0.35 }, - related: ["chill", "relaxed"] - }, - heavy: { - type: "descriptor", - featureProfile: { energy: 0.85, acousticness: 0.15 }, - related: ["intense", "aggressive", "metal"] - }, - soft: { - type: "descriptor", - featureProfile: { energy: 0.25, acousticness: 0.6 }, - related: ["gentle", "quiet", "mellow"] - }, - loud: { - type: "descriptor", - featureProfile: { energy: 0.85 }, - related: ["intense", "powerful"] - }, - acoustic: { - type: "descriptor", - featureProfile: { acousticness: 0.9, instrumentalness: 0.4 }, - related: ["unplugged", "folk"] - }, - vocal: { - type: "descriptor", - featureProfile: { instrumentalness: 0.1, speechiness: 0.2 }, - related: ["singing", "lyrics"] - }, - instrumental: { - type: "descriptor", - featureProfile: { instrumentalness: 0.9, speechiness: 0.05 }, - related: ["no vocals"] - }, - danceable: { - type: "descriptor", - featureProfile: { danceability: 0.85, energy: 0.7 }, - related: ["groovy", "rhythmic"] - }, - synth: { - type: "descriptor", - featureProfile: { acousticness: 0.1, instrumentalness: 0.5 }, - related: ["electronic", "synthesizer"] - }, - bass: { - type: "descriptor", - featureProfile: { energy: 0.7, acousticness: 0.1 }, - related: ["heavy", "dubstep", "dnb"] - }, - guitar: { - type: "descriptor", - featureProfile: { acousticness: 0.5 }, - related: ["rock", "folk", "blues"] - }, - piano: { - type: "descriptor", - featureProfile: { acousticness: 0.7, instrumentalness: 0.6 }, - related: ["classical", "jazz"] - }, - orchestral: { - type: "descriptor", - featureProfile: { instrumentalness: 0.95, acousticness: 0.85 }, - related: ["classical", "cinematic", "epic"] - }, -}; - -// Helper to get all term names -export const VOCABULARY_TERMS = Object.keys(VOCAB_DEFINITIONS); -``` - -**Step 2: Verify file created** - -Run: `ls -la backend/src/data/featureProfiles.ts` -Expected: File exists - -**Step 3: Commit** - -```bash -git add backend/src/data/featureProfiles.ts -git commit -m "feat(vibe): add research-based feature profiles for vocabulary terms" -``` - ---- - -## Task 2: Create Vocabulary Service - -**Files:** -- Create: `backend/src/services/vibeVocabulary.ts` -- Create: `backend/src/data/vibe-vocabulary.json` (placeholder) - -**Step 1: Create the vocabulary service** - -```typescript -// backend/src/services/vibeVocabulary.ts - -import { readFileSync, existsSync } from "fs"; -import { join } from "path"; -import { logger } from "../utils/logger"; -import { VOCAB_DEFINITIONS, FeatureProfile, TermType } from "../data/featureProfiles"; - -export interface VocabTerm { - name: string; - type: TermType; - embedding: number[]; - featureProfile: FeatureProfile; - related?: string[]; -} - -export interface Vocabulary { - terms: Record; - version: string; - generatedAt: string; -} - -export interface QueryExpansionResult { - embedding: number[]; - genreConfidence: number; - matchedTerms: VocabTerm[]; - originalQuery: string; -} - -let vocabulary: Vocabulary | null = null; - -/** - * Load vocabulary from JSON file. Call at startup. - */ -export function loadVocabulary(): Vocabulary | null { - const vocabPath = join(__dirname, "../data/vibe-vocabulary.json"); - - if (!existsSync(vocabPath)) { - logger.warn("[VIBE-VOCAB] Vocabulary file not found. Run generateVibeVocabulary script."); - return null; - } - - try { - const data = JSON.parse(readFileSync(vocabPath, "utf-8")); - vocabulary = data as Vocabulary; - logger.info(`[VIBE-VOCAB] Loaded ${Object.keys(vocabulary.terms).length} vocabulary terms`); - return vocabulary; - } catch (error) { - logger.error("[VIBE-VOCAB] Failed to load vocabulary:", error); - return null; - } -} - -/** - * Get loaded vocabulary (or attempt to load if not loaded) - */ -export function getVocabulary(): Vocabulary | null { - if (!vocabulary) { - return loadVocabulary(); - } - return vocabulary; -} - -/** - * Calculate cosine similarity between two vectors - */ -export function cosineSimilarity(a: number[], b: number[]): number { - if (a.length !== b.length) return 0; - - let dotProduct = 0; - let normA = 0; - let normB = 0; - - for (let i = 0; i < a.length; i++) { - dotProduct += a[i] * b[i]; - normA += a[i] * a[i]; - normB += b[i] * b[i]; - } - - const denominator = Math.sqrt(normA) * Math.sqrt(normB); - return denominator === 0 ? 0 : dotProduct / denominator; -} - -/** - * Calculate weighted average of multiple embeddings - */ -export function blendEmbeddings( - items: Array<{ embedding: number[]; weight: number }> -): number[] { - if (items.length === 0) return []; - - const dim = items[0].embedding.length; - const result = new Array(dim).fill(0); - let totalWeight = 0; - - for (const { embedding, weight } of items) { - for (let i = 0; i < dim; i++) { - result[i] += embedding[i] * weight; - } - totalWeight += weight; - } - - if (totalWeight > 0) { - for (let i = 0; i < dim; i++) { - result[i] /= totalWeight; - } - } - - return result; -} - -/** - * Find vocabulary terms similar to a query embedding - */ -export function findSimilarTerms( - queryEmbedding: number[], - vocab: Vocabulary, - minSimilarity: number = 0.55, - maxTerms: number = 5 -): Array<{ term: VocabTerm; similarity: number }> { - const matches: Array<{ term: VocabTerm; similarity: number }> = []; - - for (const [name, term] of Object.entries(vocab.terms)) { - const similarity = cosineSimilarity(queryEmbedding, term.embedding); - if (similarity >= minSimilarity) { - matches.push({ term, similarity }); - } - } - - return matches - .sort((a, b) => b.similarity - a.similarity) - .slice(0, maxTerms); -} - -/** - * Expand a query using vocabulary term matching - */ -export function expandQueryWithVocabulary( - queryEmbedding: number[], - originalQuery: string, - vocab: Vocabulary -): QueryExpansionResult { - // Find similar vocabulary terms - const matches = findSimilarTerms(queryEmbedding, vocab, 0.55, 5); - - if (matches.length === 0) { - // No matches - return original embedding - return { - embedding: queryEmbedding, - genreConfidence: 0, - matchedTerms: [], - originalQuery - }; - } - - // Calculate genre confidence (highest similarity to a genre term) - const genreMatches = matches.filter(m => m.term.type === "genre"); - const genreConfidence = genreMatches.length > 0 ? genreMatches[0].similarity : 0; - - // Blend embeddings: 60% original query, 40% distributed among matches - const embeddingItems: Array<{ embedding: number[]; weight: number }> = [ - { embedding: queryEmbedding, weight: 0.6 } - ]; - - const matchWeight = 0.4 / matches.length; - for (const match of matches) { - embeddingItems.push({ - embedding: match.term.embedding, - weight: matchWeight * match.similarity - }); - } - - const blendedEmbedding = blendEmbeddings(embeddingItems); - - return { - embedding: blendedEmbedding, - genreConfidence, - matchedTerms: matches.map(m => m.term), - originalQuery - }; -} - -/** - * Blend multiple feature profiles into a target profile - */ -export function blendFeatureProfiles(terms: VocabTerm[]): FeatureProfile { - if (terms.length === 0) return {}; - - const features = ["energy", "valence", "danceability", "acousticness", - "instrumentalness", "arousal", "speechiness"] as const; - - const result: FeatureProfile = {}; - - for (const feature of features) { - const values = terms - .map(t => t.featureProfile[feature]) - .filter((v): v is number => v !== undefined); - - if (values.length > 0) { - result[feature] = values.reduce((a, b) => a + b, 0) / values.length; - } - } - - return result; -} - -/** - * Calculate how well a track's features match a target profile - */ -export function calculateFeatureMatch( - trackFeatures: Record, - targetProfile: FeatureProfile -): number { - let score = 0; - let count = 0; - - for (const [feature, targetValue] of Object.entries(targetProfile)) { - if (targetValue === undefined) continue; - - const trackValue = trackFeatures[feature] ?? 0.5; - const match = 1 - Math.abs(trackValue - targetValue); - score += match; - count++; - } - - return count > 0 ? score / count : 0.5; -} - -/** - * Re-rank CLAP candidates using audio features - */ -export function rerankWithFeatures( - candidates: T[], - matchedTerms: VocabTerm[], - genreConfidence: number -): Array { - // Build composite feature profile from matched terms - const targetProfile = blendFeatureProfiles(matchedTerms); - - // Calculate dynamic weights based on genre confidence - // High confidence (0.8+) → 40% CLAP, 60% features - // Low confidence (0.3) → 80% CLAP, 20% features - const featureWeight = 0.2 + (genreConfidence * 0.5); - const clapWeight = 1 - featureWeight; - - logger.debug(`[VIBE-RERANK] Genre confidence: ${(genreConfidence * 100).toFixed(0)}%, ` + - `Weights: CLAP ${(clapWeight * 100).toFixed(0)}% / Features ${(featureWeight * 100).toFixed(0)}%`); - - return candidates.map(track => { - // CLAP score: convert distance to 0-1 similarity - const clapScore = Math.max(0, 1 - (track.distance / 2)); - - // Feature score - const trackFeatures: Record = { - energy: track.energy, - valence: track.valence, - danceability: track.danceability, - acousticness: track.acousticness, - instrumentalness: track.instrumentalness, - arousal: track.arousal, - speechiness: track.speechiness - }; - - const featureScore = Object.keys(targetProfile).length > 0 - ? calculateFeatureMatch(trackFeatures, targetProfile) - : 0.5; - - // Blend scores - const finalScore = (clapWeight * clapScore) + (featureWeight * featureScore); - - return { - ...track, - finalScore, - clapScore, - featureScore - }; - }) - .sort((a, b) => b.finalScore - a.finalScore); -} -``` - -**Step 2: Create placeholder vocabulary JSON** - -```json -{ - "version": "0.0.0", - "generatedAt": "placeholder", - "terms": {} -} -``` - -Save to: `backend/src/data/vibe-vocabulary.json` - -**Step 3: Verify files created** - -Run: `ls -la backend/src/services/vibeVocabulary.ts backend/src/data/vibe-vocabulary.json` -Expected: Both files exist - -**Step 4: Type check** - -Run: `cd backend && npm run build 2>&1 | tail -5` -Expected: No errors - -**Step 5: Commit** - -```bash -git add backend/src/services/vibeVocabulary.ts backend/src/data/vibe-vocabulary.json -git commit -m "feat(vibe): add vocabulary service for query expansion and re-ranking" -``` - ---- - -## Task 3: Create Vocabulary Generation Script - -**Files:** -- Create: `backend/scripts/generateVibeVocabulary.ts` - -**Step 1: Create the generation script** - -```typescript -// backend/scripts/generateVibeVocabulary.ts - -import { createClient } from "redis"; -import { randomUUID } from "crypto"; -import { writeFileSync } from "fs"; -import { join } from "path"; -import { VOCAB_DEFINITIONS, VOCABULARY_TERMS } from "../src/data/featureProfiles"; - -const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; - -interface VocabTerm { - name: string; - type: string; - embedding: number[]; - featureProfile: Record; - related?: string[]; -} - -async function getClapTextEmbedding( - redisClient: ReturnType, - text: string -): Promise { - const requestId = randomUUID(); - const responseChannel = `audio:text:embed:response:${requestId}`; - const requestChannel = "audio:text:embed"; - - const subscriber = redisClient.duplicate(); - await subscriber.connect(); - - try { - return await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`Timeout getting embedding for: ${text}`)); - }, 30000); - - subscriber.subscribe(responseChannel, (message) => { - clearTimeout(timeout); - try { - const data = JSON.parse(message); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.embedding); - } - } catch (e) { - reject(new Error("Invalid response")); - } - }); - - redisClient.publish( - requestChannel, - JSON.stringify({ requestId, text }) - ); - }); - } finally { - await subscriber.unsubscribe(responseChannel); - await subscriber.disconnect(); - } -} - -async function main() { - console.log("Connecting to Redis..."); - const redisClient = createClient({ url: REDIS_URL }); - await redisClient.connect(); - - console.log(`Generating embeddings for ${VOCABULARY_TERMS.length} terms...`); - - const terms: Record = {}; - let success = 0; - let failed = 0; - - for (const termName of VOCABULARY_TERMS) { - const definition = VOCAB_DEFINITIONS[termName]; - - try { - process.stdout.write(` ${termName}... `); - const embedding = await getClapTextEmbedding(redisClient, termName); - - terms[termName] = { - name: termName, - type: definition.type, - embedding, - featureProfile: definition.featureProfile, - related: definition.related - }; - - console.log(`OK (${embedding.length} dims)`); - success++; - } catch (error) { - console.log(`FAILED: ${error}`); - failed++; - } - - // Small delay to not overwhelm the CLAP service - await new Promise(r => setTimeout(r, 100)); - } - - const vocabulary = { - version: "1.0.0", - generatedAt: new Date().toISOString(), - terms - }; - - const outputPath = join(__dirname, "../src/data/vibe-vocabulary.json"); - writeFileSync(outputPath, JSON.stringify(vocabulary, null, 2)); - - console.log(`\nDone! ${success} terms generated, ${failed} failed.`); - console.log(`Vocabulary saved to: ${outputPath}`); - - await redisClient.disconnect(); -} - -main().catch(console.error); -``` - -**Step 2: Add script to package.json** - -In `backend/package.json`, add to scripts: - -```json -"generate:vocabulary": "ts-node scripts/generateVibeVocabulary.ts" -``` - -**Step 3: Type check** - -Run: `cd backend && npx tsc --noEmit scripts/generateVibeVocabulary.ts 2>&1 | head -10` -Expected: No errors (or minor config issues that don't block execution) - -**Step 4: Commit** - -```bash -git add backend/scripts/generateVibeVocabulary.ts backend/package.json -git commit -m "feat(vibe): add vocabulary generation script" -``` - ---- - -## Task 4: Integrate Enhanced Search into Vibe Route - -**Files:** -- Modify: `backend/src/routes/vibe.ts` - -**Step 1: Update vibe.ts imports** - -Add at top of file: - -```typescript -import { - getVocabulary, - expandQueryWithVocabulary, - rerankWithFeatures, - loadVocabulary -} from "../services/vibeVocabulary"; - -// Load vocabulary at module initialization -loadVocabulary(); -``` - -**Step 2: Update TextSearchResult interface to include audio features** - -```typescript -interface TextSearchResult { - id: string; - title: string; - duration: number; - trackNo: number; - distance: number; - albumId: string; - albumTitle: string; - albumCoverUrl: string | null; - artistId: string; - artistName: string; - // Audio features for re-ranking - energy: number | null; - valence: number | null; - danceability: number | null; - acousticness: number | null; - instrumentalness: number | null; - arousal: number | null; - speechiness: number | null; -} -``` - -**Step 3: Update the search SQL query to include audio features** - -Replace the SQL query in POST /search with: - -```typescript - const similarTracks = await prisma.$queryRaw` - SELECT - t.id, - t.title, - t.duration, - t."trackNo", - te.embedding <=> ${searchEmbedding}::vector AS distance, - a.id as "albumId", - a.title as "albumTitle", - a."coverUrl" as "albumCoverUrl", - ar.id as "artistId", - ar.name as "artistName", - t.energy, - t.valence, - t.danceability, - t.acousticness, - t.instrumentalness, - t.arousal, - t.speechiness - FROM track_embeddings te - JOIN "Track" t ON te.track_id = t.id - JOIN "Album" a ON t."albumId" = a.id - JOIN "Artist" ar ON a."artistId" = ar.id - WHERE te.embedding <=> ${searchEmbedding}::vector <= ${maxDistance} - ORDER BY te.embedding <=> ${searchEmbedding}::vector - LIMIT ${limit * 3} - `; -``` - -Note: `limit * 3` to fetch more candidates for re-ranking. - -**Step 4: Add query expansion and re-ranking logic** - -After getting `textEmbedding` and before the SQL query, add: - -```typescript - // Query expansion with vocabulary - const vocab = getVocabulary(); - let searchEmbedding = textEmbedding; - let genreConfidence = 0; - let matchedTerms: any[] = []; - - if (vocab) { - const expansion = expandQueryWithVocabulary(textEmbedding, query.trim(), vocab); - searchEmbedding = expansion.embedding; - genreConfidence = expansion.genreConfidence; - matchedTerms = expansion.matchedTerms; - - logger.info(`[VIBE-SEARCH] Query "${query.trim()}" expanded with terms: ${matchedTerms.map(t => t.name).join(", ") || "none"}, genre confidence: ${(genreConfidence * 100).toFixed(0)}%`); - } -``` - -**Step 5: Add re-ranking after fetching candidates** - -After the SQL query, before building the response, add: - -```typescript - // Re-rank using audio features if we have vocabulary matches - let rankedTracks = similarTracks; - if (vocab && matchedTerms.length > 0) { - const reranked = rerankWithFeatures(similarTracks, matchedTerms, genreConfidence); - rankedTracks = reranked.slice(0, limit); - - logger.info(`[VIBE-SEARCH] Re-ranked ${similarTracks.length} candidates, top result: ${rankedTracks[0]?.title || "none"}`); - } else { - rankedTracks = similarTracks.slice(0, limit); - } -``` - -**Step 6: Update response to use rankedTracks** - -```typescript - const tracks = rankedTracks.map((row) => ({ - id: row.id, - title: row.title, - duration: row.duration, - trackNo: row.trackNo, - distance: row.distance, - similarity: "finalScore" in row ? (row as any).finalScore : distanceToSimilarity(row.distance), - album: { - id: row.albumId, - title: row.albumTitle, - coverUrl: row.albumCoverUrl, - }, - artist: { - id: row.artistId, - name: row.artistName, - }, - })); - - res.json({ - query: query.trim(), - tracks, - minSimilarity: similarityThreshold, - totalAboveThreshold: tracks.length, - debug: { - matchedTerms: matchedTerms.map(t => t.name), - genreConfidence, - featureWeight: matchedTerms.length > 0 ? 0.2 + (genreConfidence * 0.5) : 0 - } - }); -``` - -**Step 7: Type check** - -Run: `cd backend && npm run build 2>&1 | tail -10` -Expected: No errors - -**Step 8: Commit** - -```bash -git add backend/src/routes/vibe.ts -git commit -m "feat(vibe): integrate vocabulary expansion and feature re-ranking into search" -``` - ---- - -## Task 5: Generate Vocabulary and Test - -**Step 1: Ensure CLAP analyzer is running** - -Run: `docker compose ps audio-analyzer-clap` -Expected: Shows "running" or "Up" - -If not running: -Run: `docker compose up -d audio-analyzer-clap` - -**Step 2: Generate vocabulary** - -Run: `cd backend && npm run generate:vocabulary` -Expected: Output showing each term being processed, ending with success message - -**Step 3: Verify vocabulary file** - -Run: `head -50 backend/src/data/vibe-vocabulary.json` -Expected: JSON with terms containing embeddings (512-dimensional arrays) - -**Step 4: Rebuild and restart backend** - -Run: `cd /run/media/chevron7/Storage/Projects/lidify && docker compose restart backend` -Expected: Container restarts - -**Step 5: Test search in browser** - -- Navigate to /vibe page -- Search "electronic" -- Verify results show more electronic-sounding tracks than before -- Check browser network tab for debug info in response - -**Step 6: Commit vocabulary** - -```bash -git add backend/src/data/vibe-vocabulary.json -git commit -m "feat(vibe): generate vocabulary embeddings" -``` - ---- - -## Task 6: Update Frontend Types - -**Files:** -- Modify: `frontend/lib/api.ts` - -**Step 1: Update vibeSearch return type** - -```typescript - async vibeSearch(query: string, limit = 20) { - return this.request<{ - query: string; - tracks: Array<{ - id: string; - title: string; - duration: number; - trackNo: number; - distance: number; - similarity: number; - album: { - id: string; - title: string; - coverUrl: string | null; - }; - artist: { - id: string; - name: string; - }; - }>; - minSimilarity: number; - totalAboveThreshold: number; - debug?: { - matchedTerms: string[]; - genreConfidence: number; - featureWeight: number; - }; - }>("/vibe/search", { - method: "POST", - body: JSON.stringify({ query, limit }), - }); - } -``` - -**Step 2: Type check frontend** - -Run: `cd frontend && npx tsc --noEmit 2>&1 | head -10` -Expected: No errors - -**Step 3: Commit** - -```bash -git add frontend/lib/api.ts -git commit -m "feat(vibe): update frontend types for enhanced search response" -``` - ---- - -## Summary - -After completing all tasks: - -1. **Feature profiles** define target audio characteristics for 70+ terms -2. **Vocabulary service** handles query expansion and re-ranking -3. **Generation script** creates CLAP embeddings for all terms -4. **Vibe route** integrates the new pipeline -5. **Frontend types** updated for debug info - -The enhanced search will: -- Expand "electronic" to include related terms, boosting accuracy -- Re-rank results using audio features when genre intent is detected -- Fall back to CLAP-only when vocabulary isn't available -- Provide debug info for tuning diff --git a/frontend/app/vibe/page.tsx b/frontend/app/vibe/page.tsx index 0a1155f..7f3f6e8 100644 --- a/frontend/app/vibe/page.tsx +++ b/frontend/app/vibe/page.tsx @@ -21,10 +21,6 @@ import { AudioWaveform, } from "lucide-react"; -// ============================================ -// TYPES -// ============================================ - interface TrackFeatures { energy: number; valence: number; @@ -132,9 +128,6 @@ function distanceToSimilarity(distance: number): number { return Math.max(0, 1 - distance / 2); } -// ============================================ -// COVER IMAGE -// ============================================ function CoverImage({ coverUrl, title, @@ -182,9 +175,6 @@ function CoverImage({ ); } -// ============================================ -// SIMILARITY BADGE - the signature element -// ============================================ function SimilarityBadge({ similarity, size = "md" }: { similarity: number; size?: "sm" | "md" | "lg" }) { const percent = Math.round(similarity * 100); const sizeClasses = { @@ -227,9 +217,6 @@ function SimilarityBadge({ similarity, size = "md" }: { similarity: number; size ); } -// ============================================ -// FEATURE COMPARISON - side by side bars -// ============================================ function FeatureComparison({ source, match, @@ -278,9 +265,6 @@ function FeatureComparison({ ); } -// ============================================ -// MOOD GRID - compact mood comparison -// ============================================ function MoodGrid({ source, match }: { source: TrackData; match: TrackData }) { const validMoods = MOOD_CONFIG.filter(({ key }) => { const sVal = source.features[key as keyof TrackFeatures]; @@ -319,9 +303,6 @@ function MoodGrid({ source, match }: { source: TrackData; match: TrackData }) { ); } -// ============================================ -// TAG PILLS - shared tags highlighted -// ============================================ function TagPills({ source, match }: { source: TrackData; match: TrackData }) { const sourceTags = source.lastfmTags || []; const matchTags = match.lastfmTags || []; @@ -360,9 +341,6 @@ function TagPills({ source, match }: { source: TrackData; match: TrackData }) { ); } -// ============================================ -// COMPARISON PANEL - the detailed view -// ============================================ function ComparisonPanel({ source, match, @@ -478,9 +456,6 @@ function ComparisonPanel({ ); } -// ============================================ -// TRACK ROW -// ============================================ function TrackRow({ track, index, @@ -567,9 +542,6 @@ function TrackRow({ ); } -// ============================================ -// MAIN PAGE -// ============================================ export default function VibePage() { const { vibeEmbeddings, loading: featuresLoading } = useFeatures(); diff --git a/frontend/components/player/FullPlayer.tsx b/frontend/components/player/FullPlayer.tsx index a6a44ca..6939ad7 100644 --- a/frontend/components/player/FullPlayer.tsx +++ b/frontend/components/player/FullPlayer.tsx @@ -22,9 +22,7 @@ import { RotateCw, Loader2, AudioWaveform, - AlertTriangle, RefreshCw, - X, } from "lucide-react"; import { useState, useMemo } from "react"; import { toast } from "sonner"; @@ -251,38 +249,7 @@ export function FullPlayer() { return (
- {/* Error Banner */} - {audioError && ( -
-
- - - {audioError} - -
-
- - -
-
- )} - -
+
{/* Subtle top glow */}
diff --git a/frontend/components/player/MiniPlayer.tsx b/frontend/components/player/MiniPlayer.tsx index cd58efd..6e2286b 100644 --- a/frontend/components/player/MiniPlayer.tsx +++ b/frontend/components/player/MiniPlayer.tsx @@ -24,7 +24,6 @@ import { ChevronUp, ChevronDown, AlertTriangle, - X, RefreshCw, } from "lucide-react"; import { toast } from "sonner"; @@ -208,9 +207,6 @@ export function MiniPlayer() { const seekEnabled = hasMedia && canSeek; - // ============================================ - // MOBILE/TABLET: Spotify-style compact player - // ============================================ if (isMobileOrTablet) { // Don't render if no media if (!hasMedia) return null; @@ -502,9 +498,6 @@ export function MiniPlayer() { ); } - // ============================================ - // DESKTOP: Full-featured mini player - // ============================================ return (
{/* Collapsible Vibe Panel - slides up from player */} @@ -570,38 +563,6 @@ export function MiniPlayer() { className="absolute top-0 left-0 right-0" /> - {/* Error Banner */} - {audioError && ( -
-
- - - {audioError} - -
-
- - -
-
- )} {/* Player Content */}
diff --git a/frontend/components/player/OverlayPlayer.tsx b/frontend/components/player/OverlayPlayer.tsx index 0cbb260..7870c5b 100644 --- a/frontend/components/player/OverlayPlayer.tsx +++ b/frontend/components/player/OverlayPlayer.tsx @@ -19,9 +19,7 @@ import { Loader2, RotateCcw, RotateCw, - AlertTriangle, RefreshCw, - X, } from "lucide-react"; import { formatTime, clampTime, formatTimeRemaining } from "@/utils/formatTime"; import { cn } from "@/utils/cn"; @@ -340,24 +338,6 @@ export function OverlayPlayer() {

)} - {/* Error Banner */} - {audioError && ( -
-
- - - {audioError} - -
- -
- )}
{/* Progress Bar */} diff --git a/frontend/hooks/useQueries.ts b/frontend/hooks/useQueries.ts index efdcedb..dd4f985 100644 --- a/frontend/hooks/useQueries.ts +++ b/frontend/hooks/useQueries.ts @@ -20,12 +20,6 @@ import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tansta import { api } from "@/lib/api"; import type { Artist, Album, Track } from "@/features/library/types"; -// ============================================================================ -// QUERY KEY FACTORIES -// ============================================================================ -// These functions generate consistent query keys for caching -// See: https://tanstack.com/query/latest/docs/react/guides/query-keys - export const queryKeys = { // Artist queries artist: (id: string) => ["artist", id] as const, @@ -102,10 +96,6 @@ export const queryKeys = { browseRadios: (limit?: number) => ["browse", "radios", limit] as const, }; -// ============================================================================ -// ARTIST QUERIES -// ============================================================================ - /** * Hook to fetch artist data with automatic library/discovery fallback * @@ -174,10 +164,6 @@ export function useArtistDiscoveryQuery(nameOrMbid: string | undefined) { }); } -// ============================================================================ -// ALBUM QUERIES -// ============================================================================ - /** * Hook to fetch album data with automatic library/discovery fallback * @@ -266,10 +252,6 @@ export function useAlbumsQuery(params?: { }); } -// ============================================================================ -// LIBRARY QUERIES -// ============================================================================ - /** * Hook to fetch recently listened items (Continue Listening) * @@ -308,10 +290,6 @@ export function useRecentlyAddedQuery(limit: number = 10) { }); } -// ============================================================================ -// LIBRARY PAGE QUERIES (Artists/Albums/Tracks with pagination) -// ============================================================================ - export type LibraryFilter = "owned" | "discovery" | "all"; export type SortOption = "name" | "name-desc" | "recent" | "tracks"; @@ -592,10 +570,6 @@ export function useLibraryTracksQuery({ }); } -// ============================================================================ -// RECOMMENDATION QUERIES -// ============================================================================ - /** * Hook to fetch personalized recommendations * @@ -662,10 +636,6 @@ export function useSimilarAlbumsQuery( }); } -// ============================================================================ -// SEARCH QUERIES -// ============================================================================ - /** * Hook to search library with debouncing * @@ -722,10 +692,6 @@ export function useDiscoverSearchQuery( }); } -// ============================================================================ -// PLAYLIST QUERIES -// ============================================================================ - /** * Hook to fetch all playlists * @@ -765,10 +731,6 @@ export function usePlaylistQuery(id: string | undefined) { }); } -// ============================================================================ -// MIX QUERIES -// ============================================================================ - /** * Hook to fetch all mixes (Made For You) * @@ -805,10 +767,6 @@ export function useMixQuery(id: string | undefined) { }); } -// ============================================================================ -// POPULAR ARTISTS QUERY -// ============================================================================ - /** * Hook to fetch popular artists from Last.fm * @@ -828,10 +786,6 @@ export function usePopularArtistsQuery(limit: number = 20) { }); } -// ============================================================================ -// AUDIOBOOK QUERIES -// ============================================================================ - /** * Hook to fetch all audiobooks * @@ -863,10 +817,6 @@ export function useAudiobookQuery(id: string | undefined) { }); } -// ============================================================================ -// PODCAST QUERIES -// ============================================================================ - /** * Hook to fetch all subscribed podcasts * @@ -930,11 +880,6 @@ export function useTopPodcastsQuery(limit: number = 20, genreId?: number) { }); } -// ============================================================================ -// MUTATION HOOKS -// ============================================================================ -// These hooks handle data modifications and automatically invalidate related queries - /** * Hook to refresh mixes with cache invalidation * @@ -1041,10 +986,6 @@ export function useDeletePlaylistMutation() { }); } -// ============================================================================ -// BROWSE QUERIES (Deezer Playlists/Radios) -// ============================================================================ - interface PlaylistPreview { id: string; source: string; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index bc9a836..6578d9d 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1702,10 +1702,6 @@ class ApiClient { return this.delete(`/api-keys/${id}`); } - // ============================================ - // Notifications & Activity Panel - // ============================================ - async getNotifications(): Promise< Array<{ id: string; diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 24fd8c0..e9a48b9 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -86,8 +86,10 @@ const nextConfig: NextConfig = { ]; }, // Proxy API requests to backend (for Docker all-in-one container) + // Use NEXT_PUBLIC_BACKEND_URL if set (build-time), otherwise default to localhost:3006 + // At runtime, Next.js will proxy /api/* requests to the backend async rewrites() { - const backendUrl = process.env.BACKEND_URL || "http://127.0.0.1:3006"; + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:3006"; return [ { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 062d2a0..3c1a366 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "lidify-frontend", - "version": "1.3.7", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lidify-frontend", - "version": "1.3.7", + "version": "1.4.0", "license": "GPL-3.0", "dependencies": { "@tanstack/react-query": "^5.90.10", @@ -1576,6 +1576,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -3673,6 +3733,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", diff --git a/frontend/package.json b/frontend/package.json index c2f33d4..16017af 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "lidify-frontend", - "version": "1.3.8", + "version": "1.4.0", "description": "Lidify web frontend", "license": "GPL-3.0", "repository": { diff --git a/package-lock.json b/package-lock.json index 8481a28..b2b5b1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,5 +2,25 @@ "name": "lidify", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "devDependencies": { + "typescript": "^5.9.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..85cde15 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/services/audio-analyzer-clap/analyzer.py b/services/audio-analyzer-clap/analyzer.py index 173a9a2..0e80ea4 100644 --- a/services/audio-analyzer-clap/analyzer.py +++ b/services/audio-analyzer-clap/analyzer.py @@ -43,6 +43,14 @@ os.environ['NUMEXPR_MAX_THREADS'] = str(THREADS_PER_WORKER) import torch torch.set_num_threads(THREADS_PER_WORKER) +# Device detection - use GPU if available +if torch.cuda.is_available(): + DEVICE = torch.device('cuda') + GPU_NAME = torch.cuda.get_device_name(0) +else: + DEVICE = torch.device('cpu') + GPU_NAME = None + import redis import psycopg2 from psycopg2.extras import RealDictCursor @@ -104,10 +112,13 @@ class CLAPAnalyzer: ) self.model.load_ckpt('/app/models/music_audioset_epoch_15_esc_90.14.pt') - # Move to CPU explicitly (we don't use GPU in this service) - self.model = self.model.eval() + # Move to detected device (GPU if available, else CPU) + self.model = self.model.to(DEVICE).eval() - logger.info("CLAP model loaded successfully") + if GPU_NAME: + logger.info(f"CLAP model loaded successfully on GPU: {GPU_NAME}") + else: + logger.info("CLAP model loaded successfully on CPU") except Exception as e: logger.error(f"Failed to load CLAP model: {e}") traceback.print_exc() diff --git a/services/audio-analyzer/analyzer.py b/services/audio-analyzer/analyzer.py index 65222b6..6deec14 100644 --- a/services/audio-analyzer/analyzer.py +++ b/services/audio-analyzer/analyzer.py @@ -1,10 +1,8 @@ #!/usr/bin/env python3 """Audio analyzer service - Essentia-based analysis with TensorFlow ML models""" -# ============================================================================ -# CRITICAL: TensorFlow threading MUST be configured before any imports -# Environment variables are read by TensorFlow C++ runtime before initialization -# ============================================================================ +# CRITICAL: TensorFlow threading MUST be configured before any imports. +# Environment variables are read by TensorFlow C++ runtime before initialization. import os import sys @@ -111,19 +109,31 @@ except ImportError as e: # TensorFlow models via Essentia TF_MODELS_AVAILABLE = False +TF_GPU_AVAILABLE = False +TF_GPU_NAME = None TensorflowPredictMusiCNN = None try: import tensorflow as tf - # Limit TensorFlow memory usage (CPU & GPU) - try: - gpus = tf.config.experimental.list_physical_devices('GPU') + + # Detect and configure GPU + gpus = tf.config.experimental.list_physical_devices('GPU') + if gpus: + TF_GPU_AVAILABLE = True + TF_GPU_NAME = gpus[0].name + # Enable memory growth to prevent TF from allocating all GPU memory for gpu in gpus: - tf.config.experimental.set_memory_growth(gpu, True) - except Exception: - pass + try: + tf.config.experimental.set_memory_growth(gpu, True) + except RuntimeError: + pass + logger.info(f"TensorFlow GPU detected: {TF_GPU_NAME}") + else: + logger.info("TensorFlow running on CPU") + from essentia.standard import TensorflowPredictMusiCNN TF_MODELS_AVAILABLE = True - logger.info("TensorflowPredictMusiCNN available - Enhanced mode enabled") + device_str = f"GPU: {TF_GPU_NAME}" if TF_GPU_AVAILABLE else "CPU" + logger.info(f"TensorflowPredictMusiCNN available - Enhanced mode enabled ({device_str})") except ImportError as e: logger.warning(f"TensorflowPredictMusiCNN not available: {e}") logger.info("Falling back to Standard mode")