TutuAwad commited on
Commit
c4e20d4
Β·
verified Β·
1 Parent(s): eb69c9b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +270 -235
app.py CHANGED
@@ -1,50 +1,53 @@
1
  # -*- coding: utf-8 -*-
2
- """app.ipynb
3
-
4
- Automatically generated by Colab.
5
-
6
- Original file is located at
7
- https://colab.research.google.com/drive/1vb2j78WT7l9XQiXUBvl1VAtELSAzOUZJ
8
  """
9
 
10
  import os
11
  import random
 
 
12
  import numpy as np
13
  import pandas as pd
14
  import faiss
15
  import gradio as gr
 
 
16
  from sentence_transformers import SentenceTransformer
17
  from huggingface_hub import InferenceClient
18
  import spotipy
19
  from spotipy.oauth2 import SpotifyClientCredentials
20
- from difflib import SequenceMatcher
21
- import html as html_lib
22
 
 
23
 
24
- # ---------- Load data ----------
25
  CLEAN_CSV_PATH = "df_combined_clean.csv"
26
  EMB_PATH = "df_embed.npz"
27
  INDEX_PATH = "hnsw.index"
28
 
 
 
29
  df_combined = pd.read_csv(CLEAN_CSV_PATH)
30
  emb_data = np.load(EMB_PATH)
31
  df_embeddings = emb_data["df_embeddings"].astype("float32")
32
  index = faiss.read_index(INDEX_PATH)
33
 
34
- # ---------- Secrets ----------
35
 
36
  HF_TOKEN = os.getenv("HF_TOKEN")
37
 
 
38
  SPOTIFY_CLIENT_ID = os.getenv("SPOTIPY_CLIENT_ID")
39
  SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIPY_CLIENT_SECRET")
40
 
41
-
42
  # ---------- Models ----------
 
 
43
  query_embedder = SentenceTransformer("all-mpnet-base-v2")
44
 
 
45
  LLAMA_MODEL_ID = "meta-llama/Llama-2-7b-chat-hf"
46
 
47
- # Create a generic client; we'll pass model per call
48
  hf_client = None
49
  if HF_TOKEN:
50
  try:
@@ -53,25 +56,33 @@ if HF_TOKEN:
53
  print("⚠️ Could not initialize HF Inference client:", repr(e))
54
  hf_client = None
55
 
 
56
  sp = None
57
  if SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET:
58
- auth = SpotifyClientCredentials(client_id=SPOTIFY_CLIENT_ID, client_secret=SPOTIFY_CLIENT_SECRET)
59
- sp = spotipy.Spotify(auth_manager=auth)
 
 
 
 
 
 
 
60
 
61
- # ---------- Helper functions ----------
62
- def encode_query(text):
 
63
  return query_embedder.encode([text], convert_to_numpy=True).astype("float32")
64
 
 
65
  def expand_with_llama(query: str) -> str:
66
  """
67
  Enrich the query using LLaMA via HF Inference.
68
 
69
- On HF Spaces, the Inference provider can sometimes be unavailable
70
- or misconfigured (giving the StopIteration error you saw). In that
71
- case, we log and fall back to the raw query so the UI keeps working.
72
  """
73
  if hf_client is None or not HF_TOKEN:
74
- # No client/token -> behave like "no expansion"
75
  return query
76
 
77
  prompt = f"""You are helping someone search a lyrics catalog.
@@ -90,14 +101,13 @@ Output (no explanation, just titles or keywords):"""
90
  try:
91
  response = hf_client.text_generation(
92
  prompt,
93
- model=LLAMA_MODEL_ID,
94
  max_new_tokens=96,
95
  temperature=0.2,
96
  repetition_penalty=1.05,
97
  )
98
  except Exception as e:
99
-
100
- print("LLaMA expansion failed on HF, using raw query:", repr(e))
101
  return query
102
 
103
  keywords = str(response).strip().replace("\n", " ")
@@ -105,252 +115,101 @@ Output (no explanation, just titles or keywords):"""
105
  return expanded
106
 
107
 
108
- def distances_to_similarity_pct(dists):
109
- if len(dists) == 0: return np.array([])
 
110
  dmin, dmax = dists.min(), dists.max()
111
- if dmax - dmin == 0: return np.ones_like(dists) * 100
 
112
  sims = 100 * (1 - (dists - dmin) / (dmax - dmin))
113
  return sims
114
 
115
- def label_vibes(sim):
116
- if sim >= 90: return "dead-on"
117
- elif sim >= 80: return "strong vibes"
118
- elif sim >= 70: return "adjacent"
119
- elif sim >= 60: return "stretch but related"
120
- else: return "pretty random"
121
 
122
- def semantic_search(query, k=10, random_extra=0, use_llama=True):
123
- if not query.strip():
124
- return pd.DataFrame(columns=["artist","song","similarity_pct","vibes","is_random"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  q_text = expand_with_llama(query) if use_llama else query
126
  q_vec = encode_query(q_text)
127
  dists, idxs = index.search(q_vec, k)
 
128
  sem_df = df_combined.iloc[idxs[0]].copy()
129
  sem_df["similarity_pct"] = distances_to_similarity_pct(dists[0])
130
  sem_df["vibes"] = sem_df["similarity_pct"].apply(label_vibes)
131
  sem_df["is_random"] = False
 
132
  rand_df = pd.DataFrame()
133
  if random_extra > 0:
134
- chosen = np.random.choice(len(df_combined), size=min(random_extra, len(df_combined)), replace=False)
 
 
 
 
135
  rand_df = df_combined.iloc[chosen].copy()
136
  rand_df["similarity_pct"] = np.nan
137
  rand_df["vibes"] = "pure random"
138
  rand_df["is_random"] = True
 
139
  results = pd.concat([sem_df, rand_df], ignore_index=True)
140
  return results
141
 
142
- def lookup_spotify_track_smart(artist, song):
143
- if not sp: return None, None
 
 
144
  q = f"track:{song} artist:{artist}"
145
  try:
146
  results = sp.search(q, type="track", limit=3)
147
- if not results["tracks"]["items"]:
 
148
  return None, None
149
- best = max(results["tracks"]["items"],
150
- key=lambda t: SequenceMatcher(None, t["name"].lower(), song.lower()).ratio())
151
- return best["external_urls"]["spotify"], best["album"]["images"][0]["url"]
152
- except Exception:
 
 
 
 
 
 
153
  return None, None
154
 
155
- def search_pipeline(query, k=10, random_extra=0, use_llama=True):
 
156
  res = semantic_search(query, k, random_extra, use_llama)
157
- if res.empty or not sp:
158
  res["spotify_url"], res["album_image"] = None, None
159
  return res
 
160
  urls, imgs = [], []
161
  for _, r in res.iterrows():
162
- u, i = lookup_spotify_track_smart(r["artist"], r["song"])
163
- urls.append(u); imgs.append(i)
 
164
  res["spotify_url"], res["album_image"] = urls, imgs
165
  return res
166
 
167
- # ---------- HTML builders ----------
168
- def make_bg_style_html(query=None):
169
- base_top, base_bottom = "#1e293b", "#020617"
170
- return f"<style>:root {{--hf-bg-top:{base_top};--hf-bg-bottom:{base_bottom};}}</style>"
171
-
172
- def results_to_lux_html(results, query):
173
- ...
174
- # ---------- CSS ----------
175
- app_css = """
176
- @import url('https://fonts.googleapis.com/css2?family=Inter...
177
- ...
178
- # ---------- Background palette + helper ----------
179
-
180
- BG_PALETTE = [
181
- ("#1e293b", "#020617"),
182
- ("#0f172a", "#020617"),
183
- ("#0b1120", "#020617"),
184
- ("#111827", "#020617"),
185
- ("#1f2937", "#020617"),
186
- ]
187
-
188
- def make_bg_style_html():
189
- """Pick a gradient pair from the palette and emit a <style> that sets CSS vars."""
190
- top, bottom = random.choice(BG_PALETTE)
191
- return f"<style>:root {{ --hf-bg-top: {top}; --hf-bg-bottom: {bottom}; }}</style>"
192
-
193
- # ---------- Theming + helpers ----------
194
-
195
- def infer_theme(query: str):
196
- q = (query or "").lower()
197
- if any(w in q for w in ["night", "drive", "highway", "city", "neon"]):
198
- return {"name": "Midnight Drive", "emoji": "πŸŒƒ"}
199
- if any(w in q for w in ["party", "dance", "club", "crowd", "festival"]):
200
- return {"name": "Nightclub Neon", "emoji": "πŸŽ‰"}
201
- if any(w in q for w in ["shower", "bathroom", "mirror", "getting ready"]):
202
- return {"name": "Mirror Concert", "emoji": "🚿"}
203
- if any(w in q for w in ["dog", "pet", "cat", "bloopers"]):
204
- return {"name": "Pet Bloopers", "emoji": "🐢"}
205
- # default
206
- return {"name": "", "emoji": "🎧"}
207
-
208
- # ---------- DataFrame -> HTML ----------
209
-
210
- def results_to_lux_html(results: pd.DataFrame, query: str) -> str:
211
- if results is None or results.empty:
212
- return """
213
- <div id="lux-wrapper">
214
- <div id="lux-header">
215
- <div class="lux-subline">HarmoniFind β€’ Semantic playlist</div>
216
- <h1>🎧 Describe a vibe to start</h1>
217
- <p style="font-size:0.9rem;color:rgba(156,163,175,0.95);margin-top:8px;">
218
- Type a brief above, or click <strong>🎲</strong> for a fun prompt.
219
- </p>
220
- </div>
221
- </div>
222
- """
223
-
224
- theme = infer_theme(query)
225
- query_safe = html_lib.escape(query or "")
226
- emoji = theme["emoji"]
227
-
228
- cards_html = ""
229
- tracks_plain = []
230
-
231
- for _, row in results.iterrows():
232
- raw_artist = str(row.get("artist", ""))
233
- raw_song = str(row.get("song", ""))
234
-
235
- artist = html_lib.escape(raw_artist)
236
- song = html_lib.escape(raw_song)
237
-
238
- # for clipboard list
239
- tracks_plain.append(f"{raw_song} β€” {raw_artist}")
240
 
241
- is_random = bool(row.get("is_random", False))
 
 
 
 
242
 
243
- sim_pct = row.get("similarity_pct", None)
244
- if pd.isna(sim_pct) or is_random:
245
- sim_display = "β€”"
246
- score_bg = "rgba(148,163,184,0.2)"
247
- vibes = "pure random"
248
- else:
249
- sim_display = f"{float(sim_pct):.1f}%"
250
- score_bg = "rgba(34,197,94,0.14)"
251
- vibes = html_lib.escape(str(row.get("vibes", "")))
252
-
253
- url = row.get("spotify_url", None)
254
- img = row.get("album_image", None)
255
-
256
- if isinstance(img, str) and img:
257
- cover = f'<div class="lux-cover"><img src="{html_lib.escape(img)}"></div>'
258
- else:
259
- cover = '<div class="lux-cover">β™ͺ</div>'
260
-
261
- if isinstance(url, str) and url:
262
- play_btn = f'<a class="lux-play-btn" href="{html_lib.escape(url)}" target="_blank">β–ΆοΈŽ Play on Spotify</a>'
263
- else:
264
- play_btn = ""
265
-
266
- random_chip = ""
267
- if is_random:
268
- random_chip = '<span class="lux-chip">🎲 random pick</span>'
269
-
270
- cards_html += f"""
271
- <div class="lux-card">
272
- {cover}
273
- <div class="lux-main">
274
- <div class="lux-title-row">
275
- <div>
276
- <div class="lux-title">{song}</div>
277
- <div class="lux-artist">{artist}</div>
278
- </div>
279
- <div class="lux-score">
280
- <div class="lux-score-badge" style="background:{score_bg};">{sim_display}</div>
281
- <div class="lux-vibes">{vibes}</div>
282
- </div>
283
- </div>
284
- <div class="lux-bottom-row">
285
- {play_btn}
286
- {random_chip}
287
- </div>
288
- </div>
289
- </div>
290
- """
291
-
292
- # Build track list text for clipboard
293
- header_line = f"HarmoniFind results for: {query or ''}".strip()
294
- if not header_line:
295
- header_line = "HarmoniFind results"
296
- list_text = header_line + "\n\n" + "\n".join(tracks_plain)
297
- # escape for JS string
298
- js_text = (
299
- list_text
300
- .replace("\\", "\\\\")
301
- .replace("'", "\\'")
302
- .replace("\n", "\\n")
303
- )
304
-
305
- meta_html = f"""
306
- <p>Semantic matches first, plus optional 🎲 discovery if you enabled it.</p>
307
- <div class="lux-meta">
308
- <span class="lux-badge">Tracks: {len(results)}</span>
309
- <a class="lux-pill" href="javascript:void(0);" onclick="navigator.clipboard.writeText('{js_text}');">
310
- πŸ”— Copy Your HarmoniFinds
311
- </a>
312
- </div>
313
- """
314
-
315
- html = f"""
316
- <div id="lux-wrapper">
317
- <div id="lux-header">
318
- <div class="lux-subline">HarmoniFind β€’ Semantic playlist</div>
319
- <h1>{emoji} {query_safe or "Untitled vibe"}</h1>
320
- {meta_html}
321
- </div>
322
- <div class="lux-playlist-wrapper">
323
- {cards_html}
324
- </div>
325
- </div>
326
- """
327
- return html
328
-
329
- # ---------- Search + bg wrapper ----------
330
-
331
- def core_search_html(query, k, random_extra):
332
- # LLaMA expansion always ON
333
- results = search_pipeline(
334
- query=query or "",
335
- k=int(k),
336
- random_extra=int(random_extra),
337
- use_llama=True,
338
- )
339
- return results_to_lux_html(results, query or "")
340
-
341
- def search_with_bg(query, k, random_extra):
342
- """Return playlist HTML + a new background style snippet."""
343
- playlist_html = core_search_html(query, k, random_extra)
344
- bg_style_html = make_bg_style_html()
345
- return playlist_html, bg_style_html
346
-
347
- def surprise_brief():
348
- return get_random_vibe()
349
-
350
- def clear_all():
351
- # reset query, results (empty state), and bg
352
- empty_html = results_to_lux_html(None, "")
353
- return "", empty_html, make_bg_style_html()
354
 
355
  # ---------- CSS ----------
356
 
@@ -585,10 +444,187 @@ button.secondary-btn:hover {
585
  }
586
  """
587
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  # ---------- Gradio UI ----------
589
 
590
  with gr.Blocks(title="HarmoniFind") as demo:
591
- # inject CSS manually (HF version of Gradio may not support css=...)
592
  gr.HTML(f"<style>{app_css}</style>")
593
 
594
  # dynamic bg style holder (updated on each search)
@@ -619,7 +655,7 @@ with gr.Blocks(title="HarmoniFind") as demo:
619
  surprise_btn = gr.Button("🎲 Surprise me", elem_classes=["secondary-btn"])
620
  clear_btn = gr.Button("Clear", elem_classes=["secondary-btn"])
621
 
622
- # Sliders only (LLaMA always on)
623
  with gr.Accordion("Search settings", open=False):
624
  with gr.Row():
625
  k_slider = gr.Slider(5, 50, value=10, step=1, label="# semantic matches")
@@ -659,4 +695,3 @@ with gr.Blocks(title="HarmoniFind") as demo:
659
  if __name__ == "__main__":
660
  port = int(os.getenv("PORT", 7860))
661
  demo.launch(server_name="0.0.0.0", server_port=port)
662
-
 
1
  # -*- coding: utf-8 -*-
2
+ """
3
+ HarmoniFind – Semantic Spotify Search
4
+ HF Spaces app.py
 
 
 
5
  """
6
 
7
  import os
8
  import random
9
+ from difflib import SequenceMatcher
10
+
11
  import numpy as np
12
  import pandas as pd
13
  import faiss
14
  import gradio as gr
15
+ import html as html_lib
16
+
17
  from sentence_transformers import SentenceTransformer
18
  from huggingface_hub import InferenceClient
19
  import spotipy
20
  from spotipy.oauth2 import SpotifyClientCredentials
 
 
21
 
22
+ # ---------- Paths to precomputed data ----------
23
 
 
24
  CLEAN_CSV_PATH = "df_combined_clean.csv"
25
  EMB_PATH = "df_embed.npz"
26
  INDEX_PATH = "hnsw.index"
27
 
28
+ # ---------- Load data ----------
29
+
30
  df_combined = pd.read_csv(CLEAN_CSV_PATH)
31
  emb_data = np.load(EMB_PATH)
32
  df_embeddings = emb_data["df_embeddings"].astype("float32")
33
  index = faiss.read_index(INDEX_PATH)
34
 
35
+ # ---------- Secrets from env (HF Space secrets) ----------
36
 
37
  HF_TOKEN = os.getenv("HF_TOKEN")
38
 
39
+ # HF Space secrets should be named SPOTIPY_CLIENT_ID / SPOTIPY_CLIENT_SECRET
40
  SPOTIFY_CLIENT_ID = os.getenv("SPOTIPY_CLIENT_ID")
41
  SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIPY_CLIENT_SECRET")
42
 
 
43
  # ---------- Models ----------
44
+
45
+ # Query encoder (same as notebook)
46
  query_embedder = SentenceTransformer("all-mpnet-base-v2")
47
 
48
+ # LLaMA-2 for query expansion (remote HF Inference)
49
  LLAMA_MODEL_ID = "meta-llama/Llama-2-7b-chat-hf"
50
 
 
51
  hf_client = None
52
  if HF_TOKEN:
53
  try:
 
56
  print("⚠️ Could not initialize HF Inference client:", repr(e))
57
  hf_client = None
58
 
59
+ # Spotify client
60
  sp = None
61
  if SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET:
62
+ try:
63
+ auth = SpotifyClientCredentials(
64
+ client_id=SPOTIFY_CLIENT_ID,
65
+ client_secret=SPOTIFY_CLIENT_SECRET,
66
+ )
67
+ sp = spotipy.Spotify(auth_manager=auth)
68
+ except Exception as e:
69
+ print("⚠️ Could not initialize Spotify client:", repr(e))
70
+ sp = None
71
 
72
+ # ---------- Core helpers ----------
73
+
74
+ def encode_query(text: str) -> np.ndarray:
75
  return query_embedder.encode([text], convert_to_numpy=True).astype("float32")
76
 
77
+
78
  def expand_with_llama(query: str) -> str:
79
  """
80
  Enrich the query using LLaMA via HF Inference.
81
 
82
+ If anything fails (no client, provider issues, rate limits, etc.),
83
+ we log and fall back to the raw query so the app keeps working.
 
84
  """
85
  if hf_client is None or not HF_TOKEN:
 
86
  return query
87
 
88
  prompt = f"""You are helping someone search a lyrics catalog.
 
101
  try:
102
  response = hf_client.text_generation(
103
  prompt,
104
+ model=LLAMA_MODEL_ID,
105
  max_new_tokens=96,
106
  temperature=0.2,
107
  repetition_penalty=1.05,
108
  )
109
  except Exception as e:
110
+ print("⚠️ LLaMA expansion failed on HF, using raw query:", repr(e))
 
111
  return query
112
 
113
  keywords = str(response).strip().replace("\n", " ")
 
115
  return expanded
116
 
117
 
118
+ def distances_to_similarity_pct(dists: np.ndarray) -> np.ndarray:
119
+ if len(dists) == 0:
120
+ return np.array([])
121
  dmin, dmax = dists.min(), dists.max()
122
+ if dmax - dmin == 0:
123
+ return np.ones_like(dists) * 100
124
  sims = 100 * (1 - (dists - dmin) / (dmax - dmin))
125
  return sims
126
 
 
 
 
 
 
 
127
 
128
+ def label_vibes(sim: float) -> str:
129
+ if sim >= 90:
130
+ return "dead-on"
131
+ elif sim >= 80:
132
+ return "strong vibes"
133
+ elif sim >= 70:
134
+ return "adjacent"
135
+ elif sim >= 60:
136
+ return "stretch but related"
137
+ else:
138
+ return "pretty random"
139
+
140
+
141
+ def semantic_search(query: str, k: int = 10, random_extra: int = 0, use_llama: bool = True) -> pd.DataFrame:
142
+ if not query or not query.strip():
143
+ return pd.DataFrame(columns=["artist", "song", "similarity_pct", "vibes", "is_random"])
144
+
145
  q_text = expand_with_llama(query) if use_llama else query
146
  q_vec = encode_query(q_text)
147
  dists, idxs = index.search(q_vec, k)
148
+
149
  sem_df = df_combined.iloc[idxs[0]].copy()
150
  sem_df["similarity_pct"] = distances_to_similarity_pct(dists[0])
151
  sem_df["vibes"] = sem_df["similarity_pct"].apply(label_vibes)
152
  sem_df["is_random"] = False
153
+
154
  rand_df = pd.DataFrame()
155
  if random_extra > 0:
156
+ chosen = np.random.choice(
157
+ len(df_combined),
158
+ size=min(random_extra, len(df_combined)),
159
+ replace=False,
160
+ )
161
  rand_df = df_combined.iloc[chosen].copy()
162
  rand_df["similarity_pct"] = np.nan
163
  rand_df["vibes"] = "pure random"
164
  rand_df["is_random"] = True
165
+
166
  results = pd.concat([sem_df, rand_df], ignore_index=True)
167
  return results
168
 
169
+
170
+ def lookup_spotify_track_smart(artist: str, song: str):
171
+ if not sp:
172
+ return None, None
173
  q = f"track:{song} artist:{artist}"
174
  try:
175
  results = sp.search(q, type="track", limit=3)
176
+ items = results.get("tracks", {}).get("items", [])
177
+ if not items:
178
  return None, None
179
+ best = max(
180
+ items,
181
+ key=lambda t: SequenceMatcher(None, t["name"].lower(), song.lower()).ratio(),
182
+ )
183
+ url = best["external_urls"]["spotify"]
184
+ images = best["album"]["images"]
185
+ img_url = images[0]["url"] if images else None
186
+ return url, img_url
187
+ except Exception as e:
188
+ print("⚠️ Spotify search failed:", repr(e))
189
  return None, None
190
 
191
+
192
+ def search_pipeline(query: str, k: int = 10, random_extra: int = 0, use_llama: bool = True) -> pd.DataFrame:
193
  res = semantic_search(query, k, random_extra, use_llama)
194
+ if res.empty or sp is None:
195
  res["spotify_url"], res["album_image"] = None, None
196
  return res
197
+
198
  urls, imgs = [], []
199
  for _, r in res.iterrows():
200
+ u, i = lookup_spotify_track_smart(str(r["artist"]), str(r["song"]))
201
+ urls.append(u)
202
+ imgs.append(i)
203
  res["spotify_url"], res["album_image"] = urls, imgs
204
  return res
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
+ def get_random_vibe() -> str:
208
+ topics = ["late-night drives", "dog bloopers", "breakups", "sunset beaches", "college nostalgia"]
209
+ perspectives = ["first-person", "third-person", "group", "inner monologue"]
210
+ tones = ["dreamy", "chaotic", "romantic", "melancholic"]
211
+ return f"Lyrics about {random.choice(topics)}, told in {random.choice(perspectives)}, {random.choice(tones)}."
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
  # ---------- CSS ----------
215
 
 
444
  }
445
  """
446
 
447
+ # ---------- Background palette + helper ----------
448
+
449
+ BG_PALETTE = [
450
+ ("#1e293b", "#020617"),
451
+ ("#0f172a", "#020617"),
452
+ ("#0b1120", "#020617"),
453
+ ("#111827", "#020617"),
454
+ ("#1f2937", "#020617"),
455
+ ]
456
+
457
+ def make_bg_style_html() -> str:
458
+ """Pick a gradient pair from the palette and emit a <style> that sets CSS vars."""
459
+ top, bottom = random.choice(BG_PALETTE)
460
+ return f"<style>:root {{ --hf-bg-top: {top}; --hf-bg-bottom: {bottom}; }}</style>"
461
+
462
+ # ---------- Theming + helpers ----------
463
+
464
+ def infer_theme(query: str):
465
+ q = (query or "").lower()
466
+ if any(w in q for w in ["night", "drive", "highway", "city", "neon"]):
467
+ return {"name": "Midnight Drive", "emoji": "πŸŒƒ"}
468
+ if any(w in q for w in ["party", "dance", "club", "crowd", "festival"]):
469
+ return {"name": "Nightclub Neon", "emoji": "πŸŽ‰"}
470
+ if any(w in q for w in ["shower", "bathroom", "mirror", "getting ready"]):
471
+ return {"name": "Mirror Concert", "emoji": "🚿"}
472
+ if any(w in q for w in ["dog", "pet", "cat", "bloopers"]):
473
+ return {"name": "Pet Bloopers", "emoji": "🐢"}
474
+ # default
475
+ return {"name": "", "emoji": "🎧"}
476
+
477
+ # ---------- DataFrame -> HTML ----------
478
+
479
+ def results_to_lux_html(results: pd.DataFrame, query: str) -> str:
480
+ if results is None or results.empty:
481
+ return """
482
+ <div id="lux-wrapper">
483
+ <div id="lux-header">
484
+ <div class="lux-subline">HarmoniFind β€’ Semantic playlist</div>
485
+ <h1>🎧 Describe a vibe to start</h1>
486
+ <p style="font-size:0.9rem;color:rgba(156,163,175,0.95);margin-top:8px;">
487
+ Type a brief above, or click <strong>🎲</strong> for a fun prompt.
488
+ </p>
489
+ </div>
490
+ </div>
491
+ """
492
+
493
+ theme = infer_theme(query)
494
+ query_safe = html_lib.escape(query or "")
495
+ emoji = theme["emoji"]
496
+
497
+ cards_html = ""
498
+ tracks_plain = []
499
+
500
+ for _, row in results.iterrows():
501
+ raw_artist = str(row.get("artist", ""))
502
+ raw_song = str(row.get("song", ""))
503
+
504
+ artist = html_lib.escape(raw_artist)
505
+ song = html_lib.escape(raw_song)
506
+
507
+ # for clipboard list
508
+ tracks_plain.append(f"{raw_song} β€” {raw_artist}")
509
+
510
+ is_random = bool(row.get("is_random", False))
511
+
512
+ sim_pct = row.get("similarity_pct", None)
513
+ if pd.isna(sim_pct) or is_random:
514
+ sim_display = "β€”"
515
+ score_bg = "rgba(148,163,184,0.2)"
516
+ vibes = "pure random"
517
+ else:
518
+ sim_display = f"{float(sim_pct):.1f}%"
519
+ score_bg = "rgba(34,197,94,0.14)"
520
+ vibes = html_lib.escape(str(row.get("vibes", "")))
521
+
522
+ url = row.get("spotify_url", None)
523
+ img = row.get("album_image", None)
524
+
525
+ if isinstance(img, str) and img:
526
+ cover = f'<div class="lux-cover"><img src="{html_lib.escape(img)}"></div>'
527
+ else:
528
+ cover = '<div class="lux-cover">β™ͺ</div>'
529
+
530
+ if isinstance(url, str) and url:
531
+ play_btn = f'<a class="lux-play-btn" href="{html_lib.escape(url)}" target="_blank">β–ΆοΈŽ Play on Spotify</a>'
532
+ else:
533
+ play_btn = ""
534
+
535
+ random_chip = ""
536
+ if is_random:
537
+ random_chip = '<span class="lux-chip">🎲 random pick</span>'
538
+
539
+ cards_html += f"""
540
+ <div class="lux-card">
541
+ {cover}
542
+ <div class="lux-main">
543
+ <div class="lux-title-row">
544
+ <div>
545
+ <div class="lux-title">{song}</div>
546
+ <div class="lux-artist">{artist}</div>
547
+ </div>
548
+ <div class="lux-score">
549
+ <div class="lux-score-badge" style="background:{score_bg};">{sim_display}</div>
550
+ <div class="lux-vibes">{vibes}</div>
551
+ </div>
552
+ </div>
553
+ <div class="lux-bottom-row">
554
+ {play_btn}
555
+ {random_chip}
556
+ </div>
557
+ </div>
558
+ </div>
559
+ """
560
+
561
+ # Build track list text for clipboard
562
+ header_line = f"HarmoniFind results for: {query or ''}".strip()
563
+ if not header_line:
564
+ header_line = "HarmoniFind results"
565
+ list_text = header_line + "\n\n" + "\n".join(tracks_plain)
566
+ # escape for JS string
567
+ js_text = (
568
+ list_text
569
+ .replace("\\", "\\\\")
570
+ .replace("'", "\\'")
571
+ .replace("\n", "\\n")
572
+ )
573
+
574
+ meta_html = f"""
575
+ <p>Semantic matches first, plus optional 🎲 discovery if you enabled it.</p>
576
+ <div class="lux-meta">
577
+ <span class="lux-badge">Tracks: {len(results)}</span>
578
+ <a class="lux-pill" href="javascript:void(0);" onclick="navigator.clipboard.writeText('{js_text}');">
579
+ πŸ”— Copy Your HarmoniFinds
580
+ </a>
581
+ </div>
582
+ """
583
+
584
+ html = f"""
585
+ <div id="lux-wrapper">
586
+ <div id="lux-header">
587
+ <div class="lux-subline">HarmoniFind β€’ Semantic playlist</div>
588
+ <h1>{emoji} {query_safe or "Untitled vibe"}</h1>
589
+ {meta_html}
590
+ </div>
591
+ <div class="lux-playlist-wrapper">
592
+ {cards_html}
593
+ </div>
594
+ </div>
595
+ """
596
+ return html
597
+
598
+ # ---------- Search + bg wrapper ----------
599
+
600
+ def core_search_html(query, k, random_extra):
601
+ # LLaMA expansion always ON now
602
+ results = search_pipeline(
603
+ query=query or "",
604
+ k=int(k),
605
+ random_extra=int(random_extra),
606
+ use_llama=True,
607
+ )
608
+ return results_to_lux_html(results, query or "")
609
+
610
+ def search_with_bg(query, k, random_extra):
611
+ """Return playlist HTML + a new background style snippet."""
612
+ playlist_html = core_search_html(query, k, random_extra)
613
+ bg_style_html = make_bg_style_html()
614
+ return playlist_html, bg_style_html
615
+
616
+ def surprise_brief():
617
+ return get_random_vibe()
618
+
619
+ def clear_all():
620
+ # reset query, results (empty state), and bg
621
+ empty_html = results_to_lux_html(None, "")
622
+ return "", empty_html, make_bg_style_html()
623
+
624
  # ---------- Gradio UI ----------
625
 
626
  with gr.Blocks(title="HarmoniFind") as demo:
627
+ # Inject CSS manually (HF Gradio version may not support css=... kwarg)
628
  gr.HTML(f"<style>{app_css}</style>")
629
 
630
  # dynamic bg style holder (updated on each search)
 
655
  surprise_btn = gr.Button("🎲 Surprise me", elem_classes=["secondary-btn"])
656
  clear_btn = gr.Button("Clear", elem_classes=["secondary-btn"])
657
 
658
+ # Sliders only (LLaMA is always on; no checkbox)
659
  with gr.Accordion("Search settings", open=False):
660
  with gr.Row():
661
  k_slider = gr.Slider(5, 50, value=10, step=1, label="# semantic matches")
 
695
  if __name__ == "__main__":
696
  port = int(os.getenv("PORT", 7860))
697
  demo.launch(server_name="0.0.0.0", server_port=port)