-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbrain.py
More file actions
578 lines (487 loc) · 20.6 KB
/
Copy pathbrain.py
File metadata and controls
578 lines (487 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
"""
brain.py
--------
Converts a natural language music request into multiple targeted Spotify
search queries.
AI fallback chain (tried in order):
1. Google Gemini (cloud) — fast, free tier
2. Local LLM via Open WebUI (OpenAI-compatible API) — private, no rate limits
Configure the local LLM by adding these to your .env file:
LOCAL_LLM_BASE_URL=http://localhost:3000/api # Open WebUI base URL
LOCAL_LLM_API_KEY=your-openwebui-api-key # Settings -> Account -> API Keys
LOCAL_LLM_MODEL=llama3.2:latest # Model name as shown in Open WebUI
Two public functions:
get_vibe_params(user_prompt, api_key)
-> DJDirectives for a fresh request
get_continue_params(original_prompt, previous_queries, api_key)
-> DJDirectives with fresh angles, avoiding the previous queries
"""
from __future__ import annotations
from preferences import build_preference_context, load_preferences
import json
import os
from pathlib import Path
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Gemini models — tried top to bottom, lite first (higher free quota)
# ---------------------------------------------------------------------------
CANDIDATE_MODELS = [
"gemini-2.0-flash-lite",
"gemini-2.0-flash-lite-001",
"gemini-2.5-flash-lite",
"gemini-2.0-flash",
# gemma-3-4b-it excluded: does not support JSON mode
]
# ---------------------------------------------------------------------------
# Local LLM config — read from .env
# ---------------------------------------------------------------------------
def _read_env() -> dict:
"""Read .env from the project folder."""
env_path = Path(__file__).parent / ".env"
result = {}
if not env_path.exists():
return result
for line in env_path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
result[k.strip()] = v.strip().strip('"').strip("'")
return result
_ENV = _read_env()
LOCAL_LLM_BASE_URL = _ENV.get("LOCAL_LLM_BASE_URL", os.environ.get("LOCAL_LLM_BASE_URL", ""))
LOCAL_LLM_API_KEY = _ENV.get("LOCAL_LLM_API_KEY", os.environ.get("LOCAL_LLM_API_KEY", ""))
LOCAL_LLM_MODEL = _ENV.get("LOCAL_LLM_MODEL", os.environ.get("LOCAL_LLM_MODEL", "llama3.2:latest"))
# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
INITIAL_PROMPT = """
You are an expert music curator powering a Spotify AI DJ. Your job is to
generate search queries that find great, specific tracks on Spotify.
User Request: "{user_prompt}"
---
CRITICAL RULES FOR GOOD QUERIES:
1. BE SPECIFIC. Vague queries like "dark metal aggressive" return poor results.
Real artist names, song titles, and known genre terms find real tracks.
2. USE REAL ARTIST NAMES as your primary strategy. Spotify indexes artists,
albums, and song titles extremely well across all languages and scripts.
3. NON-ENGLISH & NICHE GENRES — this is critical:
- Search in the ORIGINAL LANGUAGE when relevant. Spotify indexes Cyrillic,
Chinese, Arabic, Japanese etc. natively. "Любэ" finds more than "Lyube".
- Also include transliterated versions as separate queries for safety.
- For regional/folk/cultural music, name the specific tradition, region,
or movement. "Soviet military choir", "красная армия хор" (Red Army Choir),
"Russian romance", "shanson russe" etc.
- For political/historical music genres, treat them as cultural categories
and search for the actual artists and labels in that space.
- KNOW YOUR SCENES: if someone references a specific song or artist,
identify the genre/scene they belong to and find related artists in it.
4. MIX STRATEGIES across your queries:
- Direct artist names (most reliable): "Ансамбль Александрова"
- Original-script searches: "Любэ", "Красная армия"
- Transliterated versions: "Lyube", "Krasnaya Armiya"
- Genre/scene terms: "Soviet military songs", "военные песни"
- Era or movement: "советские песни", "WWII Soviet"
- Related artists you know: name actual artists in the genre
5. QUANTITY: Generate 6-10 queries. For niche genres, use more queries
since individual searches may return fewer results.
6. REQUEST ALWAYS WINS. If the user asks for jazz, play jazz — even if their
history is all metal. The listener history below is only a tiebreaker for
vague requests. Never let it override an explicit genre, artist, or mood.
7. SOUNDTRACKS, SCORES & GAME OSTs — this is critical:
NEVER search for a film/game title as a standalone query. "Destiny" returns
songs NAMED Destiny, not the game. "Interstellar" returns a country singer.
USE SPOTIFY FIELD OPERATORS for precision. These filter by exact field:
artist:"Martin O'Donnell" — only tracks by this artist
album:"Destiny Original Soundtrack" — only tracks from this album
artist:"Hans Zimmer" album:"Inception" — both filters at once
Always prefer field operator queries over plain keyword queries for OSTs.
Also use plain queries as backup:
- Composer name alone: "Martin O'Donnell", "Mick Gordon"
- Composer + property: "Mick Gordon Doom"
Know your composers. Common ones:
Video games: Martin O'Donnell (Halo/Destiny), Mick Gordon (Doom/Wolfenstein),
Nobuo Uematsu (Final Fantasy), Koji Kondo (Mario/Zelda), Jesper Kyd (Assassin's Creed),
Gustavo Santaolalla (The Last of Us), Bear McCreary (God of War),
Yoko Shimomura (Kingdom Hearts), Disasterpeace (Celeste/Hyper Light Drifter)
Film: Hans Zimmer, John Williams, Ennio Morricone, Howard Shore, Bernard Herrmann
TV: Ramin Djawadi (Game of Thrones), Jeff Russo, Nathan Johnson
QUEUE SIZE:
- Casual listen: 20-30 tracks
- Background/work: 40-60 tracks
- Gym/long session: 70-100 tracks
Default 40 if unclear.
{preference_context}
---
EXAMPLES:
Request: "industrial metal like rammstein"
reasoning: "Mix direct artist searches with related acts and genre terms"
queries: [
"Rammstein",
"Nine Inch Nails",
"Ministry industrial",
"KMFDM",
"Marilyn Manson",
"neue deutsche haerte",
"Godflesh",
"Oomph! metal"
]
queue_size: 50
Request: "sad acoustic songs"
reasoning: "Well-known sad acoustic artists first, then broaden to genre terms"
queries: [
"Elliott Smith",
"Nick Drake",
"Iron & Wine acoustic",
"Sufjan Stevens sad",
"Damien Rice",
"Bon Iver acoustic",
"sad folk acoustic indie"
]
queue_size: 30
--- OST / SOUNDTRACK MODE ---
For any request involving game soundtracks, film scores, or TV scores:
- Set search_mode to "album"
- Your queries are ALBUM TITLES, not track searches
- The app will search for these albums on Spotify and pull all their tracks directly
- This bypasses keyword matching entirely and guarantees correct results
- Name every relevant album separately — expansions, volumes, special editions
- Also include the composer name as a standalone query (finds any loose tracks/singles)
- Do NOT use field operators in album mode — just the plain album title
Request: "play the soundtrack from destiny 1 and 2"
reasoning: "OST request — use album mode. Destiny 1 composer: Martin O'Donnell & Michael Salvatori. Destiny 2 has multiple expansion OSTs each released as separate albums. Searching album titles directly guarantees we get the actual soundtrack tracks."
queries: [
"Destiny Original Soundtrack",
"Destiny 2 Original Soundtrack",
"Destiny 2 Forsaken Original Soundtrack",
"Destiny 2 Shadowkeep Original Soundtrack",
"Destiny 2 Beyond Light Original Soundtrack",
"Destiny 2 The Witch Queen Original Soundtrack",
"Destiny 2 Lightfall Original Soundtrack",
"Martin O'Donnell Michael Salvatori"
]
queue_size: 60
search_mode: "album"
Request: "Hans Zimmer Interstellar and Inception"
reasoning: "OST request — album mode. Both are major film scores with dedicated soundtrack albums on Spotify."
queries: [
"Interstellar Original Motion Picture Soundtrack",
"Inception Original Motion Picture Soundtrack",
"Hans Zimmer"
]
queue_size: 50
search_mode: "album"
Request: "final fantasy 7 remake ost"
reasoning: "OST request — album mode. FF7 Remake has multiple soundtrack volumes. Nobuo Uematsu composed the original; Masashi Hamauzu and others did the remake."
queries: [
"Final Fantasy VII Remake Original Soundtrack",
"Final Fantasy VII Remake Intergrade Original Soundtrack",
"Final Fantasy VII Original Soundtrack",
"Nobuo Uematsu Final Fantasy VII"
]
queue_size: 55
search_mode: "album"
Request: "japanese city pop 80s"
reasoning: "City pop is a defined Japanese genre with known artists — use both Japanese script and romanized names"
queries: [
"山下達郎",
"竹内まりや",
"Tatsuro Yamashita",
"Mariya Takeuchi",
"Anri city pop",
"Miki Matsubara",
"シティポップ",
"Toshiki Kadomatsu",
"80s Japanese pop"
]
queue_size: 45
---
Output JSON only. No markdown, no explanation outside the JSON.
The JSON must have exactly these fields:
"reasoning": string (explain what genre/scene you identified and your strategy)
"queries": array of strings
"queue_size": integer
"search_mode": string ("track" for normal requests, "album" for OSTs/soundtracks/scores)
"""
CONTINUE_PROMPT = """
You are an expert music curator. The user is extending their current listening
session and wants MORE music in the same vibe — but with fresh tracks they
haven't heard yet in this session.
Original request: "{user_prompt}"
Queries already used (DO NOT repeat these — find new angles):
{previous_queries}
---
YOUR JOB:
Generate 6-10 NEW search queries that explore DIFFERENT angles from those
already used, while staying true to the original vibe. Think of this as
going deeper into the genre or sideways into related territory.
Good strategies for finding new angles:
- Artists similar to ones already searched but not yet used
- A different era of the same genre (e.g. 80s vs 90s vs modern)
- A related subgenre not yet explored
- Less mainstream / deeper cuts, B-sides, regional labels
- Cross-genre fusion (e.g. "jazz metal", "electronic punk")
- For non-English genres: try different script variants not yet used
(e.g. if Cyrillic was used, try transliterated; or vice versa)
- Regional variations within the same tradition
- Compilations or anthology searches ("best of X", "X collection")
SAME RULES APPLY:
- Prefer real artist names over abstract descriptors
- Be specific — vague queries return poor results
- Think about what's actually on Spotify
Queue size: 40 tracks.
Output JSON only. No markdown, no explanation outside the JSON.
The JSON must have exactly these fields:
"reasoning": string
"queries": array of strings
"queue_size": integer
"""
STOPWORDS = {
"play", "some", "me", "i", "want", "can", "you",
"please", "listen", "to", "for", "a", "put", "on", "the",
"something", "anything", "music", "songs", "tracks", "more",
}
class DJDirectives(BaseModel):
"""Structured output returned by the AI model."""
reasoning: str
queries: list[str] = Field(default_factory=list)
queue_size: int = 40
search_mode: str = "track" # "track" (default) or "album" (for OSTs/scores)
def _keyword_fallback(user_prompt: str) -> DJDirectives:
words = user_prompt.lower().split()
cleaned = [w for w in words if w not in STOPWORDS]
query = " ".join(cleaned) or user_prompt
return DJDirectives(
reasoning="No AI available. Using keyword fallback.",
queries=[query],
queue_size=40,
)
def _parse_local_response(text: str) -> DJDirectives | None:
"""
Parse a JSON response from a local LLM.
Local models don't always respect JSON-only output, so we extract
the first JSON object found in the response text.
"""
try:
# Strip markdown code fences if present
text = text.strip()
if text.startswith("```"):
lines = text.splitlines()
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
# Find the first { ... } block
start = text.find("{")
end = text.rfind("}") + 1
if start == -1 or end == 0:
print("[brain:local] No JSON object found in response")
return None
data = json.loads(text[start:end])
d = DJDirectives(
reasoning=data.get("reasoning", "Local LLM response"),
queries=data.get("queries", []),
queue_size=int(data.get("queue_size", 40)),
)
if not d.queries:
return None
d.queue_size = max(1, min(100, d.queue_size))
return d
except Exception as e:
print(f"[brain:local] Parse error: {e}")
return None
def _call_local_llm(prompt: str) -> DJDirectives | None:
"""
Call the local LLM via Open WebUI's OpenAI-compatible API.
Returns None if not configured or if the call fails.
"""
if not LOCAL_LLM_BASE_URL:
return None
try:
from openai import OpenAI
except ImportError:
print("[brain:local] openai package not installed — run: pip install openai")
return None
try:
client = OpenAI(
base_url=f"{LOCAL_LLM_BASE_URL.rstrip('/')}/v1",
api_key=LOCAL_LLM_API_KEY or "local", # Open WebUI needs a key; use "local" if none set
)
response = client.chat.completions.create(
model=LOCAL_LLM_MODEL,
messages=[{"role": "user", "content": prompt}],
temperature=0.7,
max_tokens=1024,
)
text = response.choices[0].message.content
print(f"[brain:local] {LOCAL_LLM_MODEL} responded ({len(text)} chars)")
return _parse_local_response(text)
except Exception as e:
print(f"[brain:local] {LOCAL_LLM_MODEL} error: {e}")
return None
def _call_gemini(prompt: str, api_key: str) -> DJDirectives | None:
"""Try each Gemini model in order. Returns None if all fail."""
client = genai.Client(api_key=api_key)
for model_name in CANDIDATE_MODELS:
try:
response = client.models.generate_content(
model=model_name,
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=DJDirectives,
),
)
if response.parsed:
d = response.parsed
d.queue_size = max(1, min(100, d.queue_size))
if d.queries:
print(f"[brain] {model_name} responded")
return d
except Exception as e:
err = str(e)
if "429" in err or "404" in err:
continue
print(f"[brain] {model_name}: {err}")
continue
return None
def _call_ai(prompt: str, api_key: str, local_only: bool = False) -> DJDirectives | None:
"""
Try AI models in priority order:
1. Gemini (if api_key is set AND local_only is False)
2. Local LLM via Open WebUI / Ollama (if configured)
Returns None if everything fails.
"""
# Try Gemini first (unless user wants local only)
if api_key and not local_only:
result = _call_gemini(prompt, api_key)
if result:
return result
print("[brain] All Gemini models failed, trying local LLM...")
elif local_only:
print("[brain] Local-only mode — skipping Gemini")
# Try local LLM
if LOCAL_LLM_BASE_URL:
result = _call_local_llm(prompt)
if result:
print(f"[brain] Local LLM ({LOCAL_LLM_MODEL}) succeeded")
return result
print(f"[brain] Local LLM failed")
else:
print("[brain] No LOCAL_LLM_BASE_URL set — configure it in Settings")
return None
PLAYLIST_PROMPT = """
You are an expert music curator powering a Spotify AI DJ.
The user has a Spotify playlist they love. Your job is to analyse the artists
and tracks in that playlist and generate search queries that will find MORE
music with a similar vibe — music that would fit right in alongside what's
already there.
User's extra instructions: "{user_prompt}"
Playlist contents (sample of up to 30 tracks):
{track_list}
---
YOUR TASK:
1. Identify the genre(s), mood, scene, era, and cultural context of this playlist
2. Identify the KEY ARTISTS that define the playlist's sound
3. Generate 8-12 search queries that will find similar music NOT already in the playlist
STRATEGIES:
- Search for artists who are similar to but distinct from the playlist artists
- Search for the genre/scene/era more broadly to catch artists you might not know
- Include non-English queries if the playlist has non-English content
- Think about: same genre different era, same era different genre, same cultural
scene, artists who often appear on the same compilations, collaborative artists
- If the user gave extra instructions, weight those heavily
CRITICAL: Be specific. Real artist names and known genre terms outperform
abstract descriptors on Spotify search.
Output JSON only. No markdown, no explanation outside the JSON.
Fields: "reasoning" (string), "queries" (array of strings), "queue_size" (integer, 40-80)
"""
def get_playlist_vibe_params(
playlist_tracks: list[dict],
user_prompt: str,
api_key: str,
local_only: bool = False,
) -> "DJDirectives":
"""
Analyse a playlist's contents and generate queries for similar music.
playlist_tracks: raw track dicts from Spotify (must have 'name' and 'artists').
"""
# Build a compact track list for the prompt (cap at 30 to stay within context)
import random
sample = list(playlist_tracks)
random.shuffle(sample)
sample = sample[:30]
lines = []
for t in sample:
artist = t["artists"][0]["name"] if t.get("artists") else "Unknown"
lines.append(f" - {t['name']} — {artist}")
track_list = "\n".join(lines)
prompt = PLAYLIST_PROMPT.format(
user_prompt=user_prompt or "Find music that fits alongside these tracks.",
track_list=track_list,
)
result = _call_ai(prompt, api_key, local_only=local_only)
if result:
return result
# Fallback: extract unique artists from the playlist and search for them
artists = list({
t["artists"][0]["name"]
for t in playlist_tracks
if t.get("artists")
})
random.shuffle(artists)
fallback_queries = artists[:8]
return DJDirectives(
reasoning="AI unavailable. Falling back to searching for playlist artists directly.",
queries=fallback_queries,
queue_size=50,
)
def get_vibe_params(user_prompt: str, api_key: str, local_only: bool = False) -> DJDirectives:
"""Convert a fresh music request into search queries."""
prefs = load_preferences()
pref_ctx = build_preference_context(prefs)
prompt = INITIAL_PROMPT.format(
user_prompt=user_prompt,
preference_context=pref_ctx,
)
result = _call_ai(prompt, api_key, local_only=local_only)
if result:
return result
return DJDirectives(
reasoning="All AI models unavailable. Using keyword fallback.",
queries=_keyword_fallback(user_prompt).queries,
queue_size=40,
)
def get_continue_params(
original_prompt: str,
previous_queries: list[str],
api_key: str,
local_only: bool = False,
queue_tracks: list[dict] | None = None,
) -> DJDirectives:
"""
Generate fresh queries to extend an existing session.
queue_tracks: list of {name, artist} dicts from the current Spotify queue.
When provided, the AI uses the actual queued tracks to find better matches
rather than relying solely on the original request text.
"""
formatted_queries = "\n".join(f" - {q}" for q in previous_queries)
if queue_tracks:
formatted_queue = "\n".join(
f" - {t['name']} by {t['artist']}" for t in queue_tracks[:20]
)
else:
formatted_queue = " (no queue data available — go by original request)"
prompt = CONTINUE_PROMPT.format(
user_prompt=original_prompt,
previous_queries=formatted_queries,
queue_tracks=formatted_queue,
)
result = _call_ai(prompt, api_key, local_only=local_only)
if result:
result.queue_size = 40
return result
return DJDirectives(
reasoning="AI unavailable for continue. Repeating original queries.",
queries=previous_queries,
queue_size=40,
)