carterwward
commited on
Commit
•
9b43d0b
1
Parent(s):
912bb65
original files
Browse files- .gitignore +9 -0
- app.py +17 -0
- data/SpotifyAudioFeaturesApril2019.csv +3 -0
- data/reduced_audio_profiles.csv +3 -0
- requirements.txt +2 -0
- src/evolutionary_alrogithm.py +310 -0
- src/playlist_builder.py +308 -0
- src/spotipy_utils.py +76 -0
.gitignore
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/venv
|
2 |
+
/venv/bin/*
|
3 |
+
.cache
|
4 |
+
*.ipynb
|
5 |
+
/src/config
|
6 |
+
/data/spotify_million_playlist_dataset_challenge
|
7 |
+
/data/songs_new.csv
|
8 |
+
__pycache__
|
9 |
+
*.code-workspace
|
app.py
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from src.playlist_builder import PlaylistBuilder
|
2 |
+
"""
|
3 |
+
NOTES:
|
4 |
+
TODO NEXT:
|
5 |
+
- add seeding through api search.
|
6 |
+
- put on huggingface before presentation.
|
7 |
+
"""
|
8 |
+
|
9 |
+
|
10 |
+
def main():
|
11 |
+
""" Run an instance of the PlaylistBuilder class developed using Gradio and Spotipy.
|
12 |
+
"""
|
13 |
+
playlist_app = PlaylistBuilder()
|
14 |
+
playlist_app.launch()
|
15 |
+
|
16 |
+
if __name__ == '__main__':
|
17 |
+
main()
|
data/SpotifyAudioFeaturesApril2019.csv
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:a6e49bb679851e58726ac74bf75ae50f5c10fce07fa9e6538ccb99a357d24648
|
3 |
+
size 20399954
|
data/reduced_audio_profiles.csv
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:aef94dacf666d357a0dfa9456c51e22057b66102d4e7194a3dba6b84997cd24d
|
3 |
+
size 6664964
|
requirements.txt
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
gradio
|
2 |
+
numpy
|
src/evolutionary_alrogithm.py
ADDED
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pandas as pd
|
2 |
+
import numpy as np
|
3 |
+
import gradio as gr
|
4 |
+
import plotly.express as px
|
5 |
+
from plotly.graph_objects import Figure
|
6 |
+
from copy import deepcopy
|
7 |
+
from random import choice
|
8 |
+
from typing import List, Set
|
9 |
+
import itertools
|
10 |
+
from sklearn.preprocessing import StandardScaler
|
11 |
+
from src.spotipy_utils import SpotifyTrack
|
12 |
+
|
13 |
+
# Initial data cleaning.
|
14 |
+
SONG_DF = pd.read_csv("data/SpotifyAudioFeaturesApril2019.csv")
|
15 |
+
REDUCED_DF = pd.read_csv("data/reduced_audio_profiles.csv")
|
16 |
+
AUDIO_DF = SONG_DF.drop(["artist_name", "track_id", "track_name", "duration_ms", "key", "mode", "time_signature", "popularity"], axis=1) # TODO: Normalize these feature-wise!
|
17 |
+
AUDIO_FEATURE_NAMES = AUDIO_DF.columns
|
18 |
+
history_df = pd.DataFrame({"x": REDUCED_DF.iloc[..., 0],
|
19 |
+
"y": REDUCED_DF.iloc[..., 1],
|
20 |
+
"status": ["none" for i in range(REDUCED_DF.shape[0])],
|
21 |
+
"name": SONG_DF["track_name"].values,
|
22 |
+
"artist": SONG_DF["artist_name"].values,
|
23 |
+
"key": SONG_DF["key"].values})
|
24 |
+
|
25 |
+
# Symbols for track statuses.
|
26 |
+
THUMBS_UP = "👍"
|
27 |
+
THUMBS_DOWN = "👎"
|
28 |
+
ADD_TO_PLAYLIST = "➕ to ⏯️"
|
29 |
+
COLOR_MAP_DISCRETE = {"none": 'grey', THUMBS_UP: "blue", THUMBS_DOWN: 'red', ADD_TO_PLAYLIST: "green"}
|
30 |
+
|
31 |
+
# Normalizing audio profile features to be able to control mutation size more precisely.
|
32 |
+
scaler = StandardScaler()
|
33 |
+
audio_features = scaler.fit_transform(AUDIO_DF.values)
|
34 |
+
sample_inds = list(np.arange(audio_features.shape[0])) # index list for easy sampling
|
35 |
+
|
36 |
+
# Mutation and crossover options.
|
37 |
+
MUTATION_OPTIONS = ["None", "Random", "Differential"]
|
38 |
+
CROSSOVER_OPTIONS = ["None", "Two-Point"]
|
39 |
+
|
40 |
+
|
41 |
+
class Individual:
|
42 |
+
""" Individual representing a track in the Evolutionary algorithm population.
|
43 |
+
"""
|
44 |
+
def __init__(self, song_index:int):
|
45 |
+
"""Constructor for an Individual.
|
46 |
+
|
47 |
+
Args:
|
48 |
+
song_index (int): Index of the track in the dataset.
|
49 |
+
"""
|
50 |
+
self.index = song_index
|
51 |
+
self.spotify_id = SONG_DF.loc[song_index, ["track_id"]][0]
|
52 |
+
self.spotify_track = SpotifyTrack(self.spotify_id) # Init a spotify track instance.
|
53 |
+
self.name = SONG_DF.loc[song_index, ["track_name"]][0]
|
54 |
+
self.artist = SONG_DF.loc[song_index, ["artist_name"]][0]
|
55 |
+
self.genome = audio_features[song_index,...] # NOTE: For now, the only genome used is the audio profile of the track.
|
56 |
+
self.status = None
|
57 |
+
|
58 |
+
|
59 |
+
def get_image_url(self) -> str:
|
60 |
+
""" Get the image for the track available on through the API.
|
61 |
+
|
62 |
+
Returns:
|
63 |
+
str: URL to the track image.
|
64 |
+
"""
|
65 |
+
return self.spotify_track.track_image
|
66 |
+
|
67 |
+
|
68 |
+
def get_preview_url(self) -> str:
|
69 |
+
""" Get the preview audio for the track available on through the API.
|
70 |
+
|
71 |
+
Returns:
|
72 |
+
str: URL to the preview audio.
|
73 |
+
"""
|
74 |
+
return self.spotify_track.track_preview_url
|
75 |
+
|
76 |
+
|
77 |
+
def get_track_as_block(self) -> str:
|
78 |
+
"""Get track name and artist name in a string.
|
79 |
+
|
80 |
+
Returns:
|
81 |
+
str: Track name and artist name.
|
82 |
+
"""
|
83 |
+
return gr.TextArea(value=f"{self.name} by {self.artist}")
|
84 |
+
|
85 |
+
def get_track_id(self) -> str:
|
86 |
+
"""Get track ID for Spotify API.
|
87 |
+
|
88 |
+
Returns:
|
89 |
+
str: Spotify track ID.
|
90 |
+
"""
|
91 |
+
return self.spotify_track.track_id
|
92 |
+
|
93 |
+
|
94 |
+
class Population:
|
95 |
+
""" Population of tracks to be evolved through interaction with a user in an effort to build a curated playlist.
|
96 |
+
"""
|
97 |
+
def __init__(self, num_songs:int, mutation_size:float, crossover_function=None, mutation_function=None):
|
98 |
+
""" Constructor of the population initializing all the tracks in the first generation.
|
99 |
+
|
100 |
+
Args:
|
101 |
+
num_songs (int): Number of tracks in the population.
|
102 |
+
mutation_size (float): Value to scale mutations.
|
103 |
+
crossover_function (function, optional): Function to perform crossover with. Defaults to None.
|
104 |
+
mutation_function (function, optional): Function to perform mutation with. Defaults to None.
|
105 |
+
"""
|
106 |
+
self.seed_track = None
|
107 |
+
self.size = num_songs
|
108 |
+
self.init_inds = np.random.choice(sample_inds, self.size)
|
109 |
+
self.pop = [Individual(ind) for ind in self.init_inds]
|
110 |
+
self.playlist_selected_songs = []
|
111 |
+
self.playlist_inds = set()
|
112 |
+
self.mutation_function = mutation_function
|
113 |
+
self.crossover_function = crossover_function
|
114 |
+
self.mutation_size = mutation_size
|
115 |
+
self.history_df = deepcopy(history_df)
|
116 |
+
|
117 |
+
|
118 |
+
def mutate(self, thumbs_up:List[Individual], thumbs_down:List[Individual], added_songs:List[Individual]):
|
119 |
+
""" Method to call assigned mutation function through.
|
120 |
+
|
121 |
+
Args:
|
122 |
+
thumbs_up (List[Individual]): List of individuals given the thumbs up.
|
123 |
+
thumbs_down (List[Individual]): List of individuals given the thumbs down.
|
124 |
+
added_songs (List[Individual]): List of individuals added to the playlist.
|
125 |
+
"""
|
126 |
+
self.mutation_function(self, thumbs_up, thumbs_down, added_songs)
|
127 |
+
|
128 |
+
|
129 |
+
def crossover(self, thumbs_up:List[Individual], thumbs_down:List[Individual], added_songs:List[Individual]):
|
130 |
+
""" Method to call assigned crossover function through.
|
131 |
+
|
132 |
+
Args:
|
133 |
+
thumbs_up (List[Individual]): List of individuals given the thumbs up.
|
134 |
+
thumbs_down (List[Individual]): List of individuals given the thumbs down.
|
135 |
+
added_songs (List[Individual]): List of individuals added to the playlist.
|
136 |
+
"""
|
137 |
+
self.crossover_function(self, thumbs_up, thumbs_down, added_songs)
|
138 |
+
|
139 |
+
|
140 |
+
def add_to_playlist(self, track:Individual):
|
141 |
+
""" Add a given track to the population's playlist.
|
142 |
+
|
143 |
+
Args:
|
144 |
+
track (Individual): Track that the user has chosen to add to the playlist.
|
145 |
+
"""
|
146 |
+
self.playlist_selected_songs.append(track)
|
147 |
+
self.playlist_inds.add(track.index)
|
148 |
+
|
149 |
+
|
150 |
+
def get_tracks_with_status(self, status:str):
|
151 |
+
""" Get tracks in the current population matching some status.
|
152 |
+
|
153 |
+
Args:
|
154 |
+
status (str): Status to be matched on.
|
155 |
+
"""
|
156 |
+
return list(filter(lambda x: x.status == status, self.pop))
|
157 |
+
|
158 |
+
|
159 |
+
def get_playlist_block_value(self) -> str:
|
160 |
+
""" Get population represented as a string.
|
161 |
+
|
162 |
+
Returns:
|
163 |
+
str: Tracks listed with name and artist name, one per line.
|
164 |
+
"""
|
165 |
+
val_string = ""
|
166 |
+
for track in self.playlist_selected_songs:
|
167 |
+
val_string += f"{track.name} by {track.artist}\n"
|
168 |
+
return val_string
|
169 |
+
|
170 |
+
|
171 |
+
def reinitialize_pop(self):
|
172 |
+
""" Randomly re-initialize the population.
|
173 |
+
"""
|
174 |
+
self.init_inds = np.random.choice(sample_inds, self.size)
|
175 |
+
self.pop = [Individual(ind) for ind in self.init_inds]
|
176 |
+
|
177 |
+
|
178 |
+
def generate_traversal_viz(self) -> Figure:
|
179 |
+
""" Generate a scatter plot marking the tracks which have been used so far and their categories.
|
180 |
+
|
181 |
+
Returns:
|
182 |
+
Figure: Plotly scatter plot.
|
183 |
+
"""
|
184 |
+
return px.scatter(self.history_df, "x", "y", color="status", color_discrete_map=COLOR_MAP_DISCRETE,labels={
|
185 |
+
"x": "",
|
186 |
+
"y": "",
|
187 |
+
},)
|
188 |
+
|
189 |
+
|
190 |
+
def update_population_history(self, thumbs_up:List[Individual], thumbs_down:List[Individual], added_songs:List[Individual]):
|
191 |
+
""" Update the DF keeping track of the traversal history.
|
192 |
+
|
193 |
+
Args:
|
194 |
+
thumbs_up (List[Individual]): List of individuals given the thumbs up.
|
195 |
+
thumbs_down (List[Individual]): List of individuals given the thumbs down.
|
196 |
+
added_songs (List[Individual]): List of individuals added to the playlist.
|
197 |
+
"""
|
198 |
+
for track in thumbs_up:
|
199 |
+
self.history_df.at[track.index, "status"] = THUMBS_UP
|
200 |
+
for track in thumbs_down:
|
201 |
+
self.history_df.at[track.index, "status"] = THUMBS_DOWN
|
202 |
+
for track in added_songs:
|
203 |
+
self.history_df.at[track.index, "status"] = ADD_TO_PLAYLIST
|
204 |
+
|
205 |
+
|
206 |
+
def get_closest_song_to_genome(population: Population, genome: np.ndarray, new_track_inds: Set[int]) -> int:
|
207 |
+
""" Find the song in the audio profile matrix that is most similar to the supplied one.
|
208 |
+
|
209 |
+
Args:
|
210 |
+
population (Population): Population of tracks.
|
211 |
+
genome (np.ndarray): Vector of audio profile features.
|
212 |
+
new_track_inds (Set[int]): Set of the track indices being added for the new generation.
|
213 |
+
|
214 |
+
Returns:
|
215 |
+
int: Index of the most similar track in the audio matrix that is not already in the population or added to the playlist.
|
216 |
+
"""
|
217 |
+
track_distances = np.mean(np.abs(genome - audio_features), axis=1)
|
218 |
+
sorted_distance_inds = np.argsort(track_distances)
|
219 |
+
current_distance_index = 0
|
220 |
+
while sorted_distance_inds[current_distance_index] in new_track_inds or sorted_distance_inds[current_distance_index] in population.playlist_inds:
|
221 |
+
current_distance_index += 1
|
222 |
+
|
223 |
+
child_index = sorted_distance_inds[current_distance_index]
|
224 |
+
child = Individual(child_index)
|
225 |
+
new_track_inds.add(child_index)
|
226 |
+
return child
|
227 |
+
|
228 |
+
|
229 |
+
def simple_crossover(population: Population, thumbs_up:List[Individual], thumbs_down:List[Individual], added_songs:List[Individual]):
|
230 |
+
""" Performs simple two point crossover.
|
231 |
+
|
232 |
+
Args:
|
233 |
+
population (Population): Population to assign new children to.
|
234 |
+
thumbs_up (List[Individual]): List of individuals given the thumbs up.
|
235 |
+
thumbs_down (List[Individual]): List of individuals given the thumbs down.
|
236 |
+
added_songs (List[Individual]): List of individuals added to the playlist.
|
237 |
+
"""
|
238 |
+
liked_tracks = list(itertools.chain(*[thumbs_up, added_songs]))
|
239 |
+
if len(liked_tracks) < 2: # Not enough tracks to perform crossover.
|
240 |
+
return
|
241 |
+
|
242 |
+
new_track_inds = set()
|
243 |
+
population_index = 0
|
244 |
+
while len(new_track_inds) < population.size:
|
245 |
+
track_a, track_b = np.random.choice(liked_tracks, 2)
|
246 |
+
crossover_point1, crossover_point2 = sorted(np.random.randint(0,len(track_a.genome),2))
|
247 |
+
|
248 |
+
track_a.genome[crossover_point1:crossover_point2+1] = track_b.genome[crossover_point1:crossover_point2+1]
|
249 |
+
track_b.genome[crossover_point1:crossover_point2+1] = track_a.genome[crossover_point1:crossover_point2+1]
|
250 |
+
|
251 |
+
child1 = get_closest_song_to_genome(population, track_a.genome, new_track_inds)
|
252 |
+
population.pop[population_index] = child1
|
253 |
+
population_index += 1
|
254 |
+
|
255 |
+
if population_index < population.size:
|
256 |
+
child2 = get_closest_song_to_genome(population, track_b.genome, new_track_inds)
|
257 |
+
population.pop[population_index] = child2
|
258 |
+
population_index += 1
|
259 |
+
|
260 |
+
|
261 |
+
def simple_mutation(population: Population, thumbs_up:List[Individual], thumbs_down:List[Individual], added_songs:List[Individual]):
|
262 |
+
""" Perform simple random mutation on a population of individuals.
|
263 |
+
|
264 |
+
Args:
|
265 |
+
population (Population): Population to assign new children to.
|
266 |
+
thumbs_up (List[Individual]): List of individuals given the thumbs up.
|
267 |
+
thumbs_down (List[Individual]): List of individuals given the thumbs down.
|
268 |
+
added_songs (List[Individual]): List of individuals added to the playlist.
|
269 |
+
"""
|
270 |
+
liked_tracks = list(itertools.chain(*[thumbs_up, added_songs]))
|
271 |
+
new_track_inds = set()
|
272 |
+
for i in range(population.size):
|
273 |
+
liked_track = choice(liked_tracks)
|
274 |
+
mutated_track_genome = liked_track.genome + np.random.uniform(size=(population.size))*population.mutation_size
|
275 |
+
child = get_closest_song_to_genome(population, mutated_track_genome, new_track_inds)
|
276 |
+
population.pop[i] = child
|
277 |
+
|
278 |
+
|
279 |
+
def differential_mutation(population: Population, thumbs_up:List[Individual], thumbs_down:List[Individual], added_songs:List[Individual]):
|
280 |
+
""" Perform differential mutation based on the user's decisions.
|
281 |
+
|
282 |
+
Args:
|
283 |
+
population (Population): Population to assign new children to.
|
284 |
+
thumbs_up (List[Individual]): List of individuals given the thumbs up.
|
285 |
+
thumbs_down (List[Individual]): List of individuals given the thumbs down.
|
286 |
+
added_songs (List[Individual]): List of individuals added to the playlist.
|
287 |
+
"""
|
288 |
+
new_track_inds = set()
|
289 |
+
for i in range(population.size):
|
290 |
+
if thumbs_down:
|
291 |
+
disliked_track = choice(thumbs_down)
|
292 |
+
else:
|
293 |
+
disliked_track = Individual(np.random.choice(sample_inds, 1)[0])
|
294 |
+
|
295 |
+
if thumbs_up:
|
296 |
+
seed_track = choice(thumbs_up)
|
297 |
+
else:
|
298 |
+
seed_track = Individual(np.random.choice(sample_inds, 1)[0])
|
299 |
+
|
300 |
+
if added_songs:
|
301 |
+
attracting_track = choice(added_songs)
|
302 |
+
elif population.playlist_selected_songs:
|
303 |
+
attracting_track = choice(population.playlist_selected_songs)
|
304 |
+
else:
|
305 |
+
attracting_track = Individual(np.random.choice(sample_inds, 1)[0])
|
306 |
+
|
307 |
+
gene_distances = attracting_track.genome - disliked_track.genome
|
308 |
+
resulting_genome = seed_track.genome + gene_distances*population.mutation_size
|
309 |
+
child = get_closest_song_to_genome(population, resulting_genome, new_track_inds)
|
310 |
+
population.pop[i] = child
|
src/playlist_builder.py
ADDED
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
from src.evolutionary_alrogithm import Population, simple_mutation, simple_crossover, differential_mutation, MUTATION_OPTIONS, CROSSOVER_OPTIONS, \
|
3 |
+
THUMBS_UP, THUMBS_DOWN, ADD_TO_PLAYLIST
|
4 |
+
from src.spotipy_utils import add_tracks_to_playlist, get_auth_manager, search_for_track
|
5 |
+
|
6 |
+
# IMGs
|
7 |
+
ADDED_TO_PLAYLIST = "✅ Added to Library!"
|
8 |
+
FAILED_TO_ADD = """❌ Failed to connect to your account. Make sure to that you clicked the link in the description to login and use the app from the window that opens there.\n
|
9 |
+
Also check to make sure have entered a valid playlist name and have reached out to the listed email to be added to the user list."""
|
10 |
+
|
11 |
+
# Evolutionary Algorithm Parameters.
|
12 |
+
NUM_SONGS = 9
|
13 |
+
DEFAULT_MUTATION_SIZE = 0.25 # NOTE: This could be turned into a parameter which is set by a slider.
|
14 |
+
SONG_CHOICES = [THUMBS_UP, THUMBS_DOWN, ADD_TO_PLAYLIST]
|
15 |
+
SEED_OPTIONS = ["yes", "no"]
|
16 |
+
|
17 |
+
APP_HEADER = """# Evolutionary Playlist Builder"""
|
18 |
+
OPTIONS_TITLE = """# 🧬 Options"""
|
19 |
+
EXPLORATION_HEADER = """## Rate of Exploration"""
|
20 |
+
PLAYLIST_HEADER = """## Current Playlist"""
|
21 |
+
|
22 |
+
|
23 |
+
def change_seed_option(seed_option):
|
24 |
+
if seed_option == SEED_OPTIONS[0]:
|
25 |
+
return gr.Textbox.update(visible=True), gr.Dropdown.update(visible=True)
|
26 |
+
else:
|
27 |
+
return gr.Textbox.update(visible=False), gr.Dropdown.update(visible=False)
|
28 |
+
|
29 |
+
|
30 |
+
class PlaylistBuilder():
|
31 |
+
"""Evolutionary interactive playlist builder via the Spotify API, Gradio, and a static dataset of audio profile features.
|
32 |
+
"""
|
33 |
+
def __init__(self):
|
34 |
+
self.demo = gr.Blocks()
|
35 |
+
with self.demo:
|
36 |
+
gr.Markdown(APP_HEADER)
|
37 |
+
|
38 |
+
with gr.Row():
|
39 |
+
# Initialize app attributes
|
40 |
+
self.do_crossover = False
|
41 |
+
self.do_mutation = False
|
42 |
+
self.population = Population(NUM_SONGS, DEFAULT_MUTATION_SIZE, mutation_function=simple_mutation, crossover_function=simple_crossover)
|
43 |
+
|
44 |
+
# Generate blank traversal visualization.
|
45 |
+
initial_traversal_history = self.population.generate_traversal_viz()
|
46 |
+
|
47 |
+
# Layout first column.
|
48 |
+
with gr.Column():
|
49 |
+
auth_manager = get_auth_manager() # Need auth manager to generate Spotify API authentication url.
|
50 |
+
gr.Markdown(
|
51 |
+
f"""
|
52 |
+
Please click this link to login: [login]({auth_manager.get_authorize_url()})\n
|
53 |
+
This app is still in development mode so if you want the ability to add a playlist to your library, email carterward4@gmail.com with your name, spotify email, and the subject as PLAYLIST ACCESS REQUEST.
|
54 |
+
""")
|
55 |
+
|
56 |
+
gr.Markdown(OPTIONS_TITLE)
|
57 |
+
|
58 |
+
crossover_radio = gr.Radio(choices=CROSSOVER_OPTIONS, value=CROSSOVER_OPTIONS[0], label="Crossover?") # radio button for crossover options.
|
59 |
+
|
60 |
+
mutation_radio = gr.Radio(choices=MUTATION_OPTIONS, value=MUTATION_OPTIONS[0], label="Mutate?") # radio button for mutation options.
|
61 |
+
|
62 |
+
gr.Markdown(EXPLORATION_HEADER, visible=False)
|
63 |
+
|
64 |
+
mutation_size_slider = gr.Slider(minimum=0.0, maximum=1.0, value=DEFAULT_MUTATION_SIZE
|
65 |
+
, interactive=True, show_label=False, visible=False) # slider controlling mutation slide. Values restricted between 0 and 1.
|
66 |
+
|
67 |
+
history_block = gr.Plot(initial_traversal_history, label="Search History") # Plot block for blank viz.
|
68 |
+
|
69 |
+
gr.Markdown(PLAYLIST_HEADER)
|
70 |
+
|
71 |
+
playlist_display = gr.TextArea(value=self.population.get_playlist_block_value(), max_lines=10000, show_label=False) # Blank playlist display.
|
72 |
+
|
73 |
+
spotify_playlist_textbox = gr.Textbox(interactive=True, label="Enter Playlist Name Here") # Username entry.
|
74 |
+
add_to_library = gr.Button(value="Add Playlist to Spotify Library") # Button to add to playlist
|
75 |
+
playlist_added_message = gr.Markdown(ADDED_TO_PLAYLIST, visible=False)
|
76 |
+
|
77 |
+
# Build grid layout with initial song art and options.
|
78 |
+
index_blocks = []
|
79 |
+
name_blocks = []
|
80 |
+
image_blocks = []
|
81 |
+
dropdown_blocks = []
|
82 |
+
preview_blocks = []
|
83 |
+
|
84 |
+
with gr.Column():
|
85 |
+
# Init seed options
|
86 |
+
seed_option = gr.Radio(SEED_OPTIONS, value=SEED_OPTIONS[-1], label="Seed generation?", show_label=True)
|
87 |
+
seed_search = gr.Textbox(interactive=True, label="Search for seed track by name", show_label=True, visible=False)
|
88 |
+
search_results = gr.Dropdown(label="Search result options", visible=False, interactive=True)
|
89 |
+
|
90 |
+
# Init population grid.
|
91 |
+
with gr.Box():
|
92 |
+
for row in range(0, self.population.size, 3): # Iterate over track rows.
|
93 |
+
with gr.Row():
|
94 |
+
for col in range(3): # Iterate over track columns.
|
95 |
+
with gr.Box():
|
96 |
+
track_index = row + col
|
97 |
+
current_song = self.population.pop[track_index]
|
98 |
+
song_preview = current_song.get_preview_url()
|
99 |
+
|
100 |
+
index_blocks.append(gr.TextArea(value=track_index, visible=False)) # Init index blocks to keep track of everything.
|
101 |
+
# Render initial images.
|
102 |
+
image_blocks.append(gr.Image(value=current_song.get_image_url(), show_label=False, visible=True).style(height=175, width=175))
|
103 |
+
# Add names.
|
104 |
+
name_blocks.append(gr.Markdown(value=f"{current_song.name} by {current_song.artist}", show_label=False))
|
105 |
+
# Dropdown for song options.
|
106 |
+
dropdown_blocks.append(gr.Dropdown(SONG_CHOICES, label = track_index, show_label=False))
|
107 |
+
# Add song preview if it exists.
|
108 |
+
if song_preview:
|
109 |
+
preview_blocks.append(gr.Audio(value=song_preview, show_label=False))
|
110 |
+
else:
|
111 |
+
preview_blocks.append(gr.Audio(value=song_preview, show_label=False, visible=False))
|
112 |
+
|
113 |
+
next_generation = gr.Button(value="Next Generation").style(full_width=True)
|
114 |
+
|
115 |
+
# Mapping change functions to class methods.
|
116 |
+
|
117 |
+
crossover_radio.change(
|
118 |
+
fn=self.set_crossover_option,
|
119 |
+
inputs=[crossover_radio]
|
120 |
+
)
|
121 |
+
|
122 |
+
mutation_radio.change(
|
123 |
+
fn=self.set_mutation_option,
|
124 |
+
inputs=[mutation_radio],
|
125 |
+
outputs=[mutation_size_slider, crossover_radio]
|
126 |
+
)
|
127 |
+
|
128 |
+
mutation_size_slider.change(
|
129 |
+
fn=self.update_mutation_size,
|
130 |
+
inputs=[mutation_size_slider]
|
131 |
+
)
|
132 |
+
|
133 |
+
|
134 |
+
seed_option.change(
|
135 |
+
fn=change_seed_option,
|
136 |
+
inputs=[seed_option],
|
137 |
+
outputs=[seed_search, search_results]
|
138 |
+
)
|
139 |
+
|
140 |
+
seed_search.change(
|
141 |
+
fn=self.search_for_seed,
|
142 |
+
inputs=[seed_search],
|
143 |
+
outputs=search_results
|
144 |
+
)
|
145 |
+
|
146 |
+
# Set function to hangle change in dropdown value for each track.
|
147 |
+
for index_block, dropdown_block in zip(index_blocks, dropdown_blocks):
|
148 |
+
dropdown_block.change(
|
149 |
+
fn=self.set_track_state,
|
150 |
+
inputs=[index_block, dropdown_block]
|
151 |
+
)
|
152 |
+
|
153 |
+
# Set function to generate next generation of tracks.
|
154 |
+
next_generation.click(
|
155 |
+
fn=self.get_next_generation,
|
156 |
+
outputs=[playlist_display, *image_blocks, *name_blocks, *dropdown_blocks, *preview_blocks, history_block]
|
157 |
+
)
|
158 |
+
|
159 |
+
add_to_library.click(
|
160 |
+
fn=self.add_playlist_to_spotify,
|
161 |
+
inputs=[spotify_playlist_textbox],
|
162 |
+
outputs=[playlist_added_message]
|
163 |
+
)
|
164 |
+
|
165 |
+
|
166 |
+
def set_crossover_option(self, radio_selection:str):
|
167 |
+
""" Set the crossover option for the internal evolutionary algorithm.
|
168 |
+
|
169 |
+
Args:
|
170 |
+
radio_selection (str): Selected crossover option.
|
171 |
+
"""
|
172 |
+
if radio_selection == CROSSOVER_OPTIONS[0]:
|
173 |
+
self.do_crossover = False
|
174 |
+
else:
|
175 |
+
self.do_crossover = True
|
176 |
+
|
177 |
+
|
178 |
+
def set_mutation_option(self, radio_selection:str) -> bool:
|
179 |
+
""" Set the mutation option for the internal evolutionary algorithm.
|
180 |
+
|
181 |
+
Args:
|
182 |
+
radio_selection (str): Selected mutation option.
|
183 |
+
|
184 |
+
Returns:
|
185 |
+
tuple: Slider update for mutation rate and Radio update for crossover options.
|
186 |
+
"""
|
187 |
+
if radio_selection == MUTATION_OPTIONS[0]:
|
188 |
+
self.do_mutation = False
|
189 |
+
updated_slider = gr.Slider.update(visible=False)
|
190 |
+
updated_crossover_button = gr.Radio.update(visible=True)
|
191 |
+
elif radio_selection == MUTATION_OPTIONS[2]:
|
192 |
+
self.do_mutation = True
|
193 |
+
updated_slider = gr.Slider.update(visible=True)
|
194 |
+
updated_crossover_button = gr.Radio.update(visible=False, value=CROSSOVER_OPTIONS[0])
|
195 |
+
|
196 |
+
self.do_crossover = False
|
197 |
+
self.population.mutation_function = differential_mutation
|
198 |
+
else:
|
199 |
+
self.do_mutation = True
|
200 |
+
updated_slider = gr.Slider.update(visible=True)
|
201 |
+
updated_crossover_button = gr.Radio.update(visible=True)
|
202 |
+
|
203 |
+
return updated_slider, updated_crossover_button
|
204 |
+
|
205 |
+
|
206 |
+
def update_mutation_size(self, mutation_size_slider_value:str):
|
207 |
+
"""Update the mutation rate according the slider value.
|
208 |
+
|
209 |
+
Args:
|
210 |
+
mutation_size_slider_value (str): Value from the slider input.
|
211 |
+
"""
|
212 |
+
self.population.mutation_size = float(mutation_size_slider_value)
|
213 |
+
|
214 |
+
|
215 |
+
def search_for_seed(self, search_term):
|
216 |
+
search_for_track(search_term)
|
217 |
+
return gr.Dropdown.update(choices=[f"Test {i}" for i in range(self.population.size)])
|
218 |
+
|
219 |
+
|
220 |
+
def set_track_state(self, track_index:str, dropdown_value:str):
|
221 |
+
"""Sets the decision status of the track for the corresponding dropdown menu.
|
222 |
+
|
223 |
+
Args:
|
224 |
+
track_index (str): Index of the track in the population array.
|
225 |
+
dropdown_value (str): Value selected from the track's decision dropdown.
|
226 |
+
"""
|
227 |
+
current_track = self.population.pop[int(track_index)]
|
228 |
+
current_track.status = dropdown_value
|
229 |
+
|
230 |
+
|
231 |
+
def get_next_generation(self):
|
232 |
+
""" Updates the display grid, the playlist display, and the historical tracking vizualization for the next generation.
|
233 |
+
|
234 |
+
Returns:
|
235 |
+
tuple: Current playlist display, track image, name, option, and preview blocks, and tracking history visualization.
|
236 |
+
"""
|
237 |
+
thumbs_up = self.population.get_tracks_with_status(THUMBS_UP)
|
238 |
+
thumbs_down = self.population.get_tracks_with_status(THUMBS_DOWN)
|
239 |
+
added_songs = self.population.get_tracks_with_status(ADD_TO_PLAYLIST)
|
240 |
+
|
241 |
+
for added_track in added_songs:
|
242 |
+
self.population.add_to_playlist(added_track)
|
243 |
+
|
244 |
+
playlist_display = gr.TextArea.update(
|
245 |
+
value=self.population.get_playlist_block_value()
|
246 |
+
)
|
247 |
+
|
248 |
+
# HANDLING FOR IF NO SONGS ARE SELECTED!
|
249 |
+
if not thumbs_up and not added_songs:
|
250 |
+
self.population.reinitialize_pop()
|
251 |
+
else:
|
252 |
+
if self.do_crossover and self.do_mutation:
|
253 |
+
self.population.crossover(thumbs_up, thumbs_down, added_songs)
|
254 |
+
self.population.mutate(self.population.pop, [], [])
|
255 |
+
elif self.do_crossover:
|
256 |
+
self.population.crossover(thumbs_up, thumbs_down, added_songs)
|
257 |
+
elif self.do_mutation:
|
258 |
+
self.population.mutate(thumbs_up, thumbs_down, added_songs)
|
259 |
+
|
260 |
+
# GET IMAGES AND PREVIEWS FOR NEW POPULATION.
|
261 |
+
image_blocks = []
|
262 |
+
name_blocks = []
|
263 |
+
preview_blocks = []
|
264 |
+
dropdown_blocks = []
|
265 |
+
for track in self.population.pop:
|
266 |
+
image_blocks.append(gr.Image.update(value=track.get_image_url()))
|
267 |
+
name_blocks.append(gr.Markdown.update(value=f"{track.name} by {track.artist}"))
|
268 |
+
dropdown_blocks.append(gr.Dropdown.update(value=None))
|
269 |
+
song_preview = track.get_preview_url()
|
270 |
+
if song_preview:
|
271 |
+
preview_blocks.append(gr.Audio.update(value=song_preview, visible=True))
|
272 |
+
else:
|
273 |
+
preview_blocks.append(gr.Audio.update(value=song_preview, visible=False))
|
274 |
+
|
275 |
+
# Update historical df and traversal visualization.
|
276 |
+
self.population.update_population_history(thumbs_up, thumbs_down, added_songs)
|
277 |
+
updated_viz = self.population.generate_traversal_viz()
|
278 |
+
new_traversal_tracker = gr.Plot.update(updated_viz)
|
279 |
+
|
280 |
+
return (playlist_display, *image_blocks, *name_blocks, *dropdown_blocks, *preview_blocks, new_traversal_tracker)
|
281 |
+
|
282 |
+
|
283 |
+
def add_playlist_to_spotify(self, playlist_name:str, request:gr.Request):
|
284 |
+
"""Add curated playlist to authenticated user's playlist library. Playlist will be added as public.
|
285 |
+
|
286 |
+
Args:
|
287 |
+
playlist_name (str): Name of the playlist to be created.
|
288 |
+
request (gr.Request): Request information containing the required key for user authentication.
|
289 |
+
|
290 |
+
Returns:
|
291 |
+
gr.Markdown.update: Message displaying the success of the attempt to add to library.
|
292 |
+
"""
|
293 |
+
code = request.headers.get("referer").split("code=")[-1]
|
294 |
+
added = add_tracks_to_playlist(code, playlist_name, self.population.playlist_selected_songs)
|
295 |
+
|
296 |
+
if not added:
|
297 |
+
return gr.Markdown.update(FAILED_TO_ADD, visible=True)
|
298 |
+
|
299 |
+
return gr.Markdown.update(ADDED_TO_PLAYLIST, visible=True)
|
300 |
+
|
301 |
+
|
302 |
+
def launch(self, share=False):
|
303 |
+
"""Launch the Gradio app.
|
304 |
+
|
305 |
+
Args:
|
306 |
+
share (bool, optional): Whether or not a temporary publicly accessible link should be generated. Defaults to False.
|
307 |
+
"""
|
308 |
+
self.demo.launch(share=share)
|
src/spotipy_utils.py
ADDED
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import spotipy
|
2 |
+
from spotipy.oauth2 import SpotifyClientCredentials
|
3 |
+
from typing import List
|
4 |
+
from src.config.spotify_credentials import CLIENT_ID, CLIENT_SECRET # TODO: Make these environment variables in hugging face space.
|
5 |
+
|
6 |
+
PLAYLIST_SCOPE = 'playlist-modify-public'
|
7 |
+
REDIRECT_URL = "http://127.0.0.1:7860"
|
8 |
+
|
9 |
+
sp_client = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials(CLIENT_ID, CLIENT_SECRET))
|
10 |
+
|
11 |
+
|
12 |
+
class SpotifyTrack:
|
13 |
+
""" Object for keeping track of the information provided through the Spotify API on a particular track.
|
14 |
+
"""
|
15 |
+
def __init__(self, track_id: str) -> None:
|
16 |
+
""" Constructor for class.
|
17 |
+
|
18 |
+
Args:
|
19 |
+
track_id (str): ID of the track on the Spotify API.
|
20 |
+
"""
|
21 |
+
self.track_id = track_id
|
22 |
+
self.raw_track_info = sp_client.track(track_id)
|
23 |
+
self.track_image = self.raw_track_info["album"]["images"][0]["url"]
|
24 |
+
self.track_preview_url = self.raw_track_info["preview_url"]
|
25 |
+
|
26 |
+
|
27 |
+
def get_auth_manager() -> spotipy.oauth2.SpotifyOAuth:
|
28 |
+
""" Generate authentication manager object.
|
29 |
+
|
30 |
+
Returns:
|
31 |
+
spotipy.oauth2.SpotifyOAuth: Spotipy authentication manager.
|
32 |
+
"""
|
33 |
+
cache_handler = spotipy.cache_handler.CacheFileHandler()
|
34 |
+
return spotipy.oauth2.SpotifyOAuth(client_id=CLIENT_ID, client_secret=CLIENT_SECRET, scope=PLAYLIST_SCOPE, redirect_uri=REDIRECT_URL,
|
35 |
+
cache_handler=cache_handler,
|
36 |
+
show_dialog=True)
|
37 |
+
|
38 |
+
|
39 |
+
def search_for_track(track_name):
|
40 |
+
search_results = sp_client.search(q = "track:" + track_name, type = "track", limit=10)
|
41 |
+
for track_info in search_results["tracks"]["items"]:
|
42 |
+
print(track_info["name"])
|
43 |
+
|
44 |
+
|
45 |
+
def add_tracks_to_playlist(code:str, playlist_id:str, tracks:List) -> bool:
|
46 |
+
""" Add tracks to a given user's Spotify Library.
|
47 |
+
|
48 |
+
Args:
|
49 |
+
code (str): Code for authentication.
|
50 |
+
playlist_id (str): Name of the playlist to be created.
|
51 |
+
tracks (List): List of the tracks to be added to the playlist.
|
52 |
+
|
53 |
+
Returns:
|
54 |
+
bool: Flag indicating whether or not the playlist was added to the library succesfully.
|
55 |
+
"""
|
56 |
+
auth_manager = get_auth_manager()
|
57 |
+
try:
|
58 |
+
auth_manager.get_access_token(code)
|
59 |
+
except spotipy.oauth2.SpotifyOauthError as e:
|
60 |
+
print(e)
|
61 |
+
return False
|
62 |
+
|
63 |
+
user_client = spotipy.Spotify(auth_manager=auth_manager)
|
64 |
+
username = user_client.current_user()["id"]
|
65 |
+
|
66 |
+
try:
|
67 |
+
playlist_result = user_client.user_playlist_create(username, playlist_id, public=True)
|
68 |
+
except spotipy.exceptions.SpotifyException as e:
|
69 |
+
print(e)
|
70 |
+
return False
|
71 |
+
|
72 |
+
playlist_id = playlist_result["id"]
|
73 |
+
tracks_to_add = ["spotify:track:" + track.get_track_id() for track in tracks]
|
74 |
+
if tracks_to_add:
|
75 |
+
user_client.user_playlist_add_tracks(username, playlist_id, tracks_to_add)
|
76 |
+
return True
|