Devlog: Groovematch (Web)
Resuming from the previous entry, Devlog: Groovematch (AI), I also wanted to use my web dev experience to connect my machine learning and recommendation models to a web interface.
Having written the ML part in Python, it made sense to also use a Python library for building the web app. I decided to go with Flask.
The Bare Bones
Getting the very minimum version of the site completed was not very hard. I started with just two routes:
- a GET for
/index.html
, which maps to the app itself - a
/get_songs
POST endpoint, which runs the recommendation system
The latter endpoint, after taking in the user input from HTML components as JSON, returns the most similar songs (specifically their names and artists) in JSON format.
@app.route('/get_songs', methods=['POST'])
def get_songs():
user_input = request.get_json()
# load in all songs
MASTER_DATASET_UPDATED_GENRES_FILE = 'master.csv'
ds = pd.read_csv(MASTER_DATASET_UPDATED_GENRES_FILE)
# filter songs based on user selection
GENRE_FILTER = user_input.get('genre')
ds = ds[ds['genre'] == GENRE_FILTER]
# run the clustering and similarity calculation
songs = get_similar_songs(ds, user_input)
return jsonify({'assignment': songs})
I used JSON so the JS used within the HTML of the web page can iterate through it easily, and create text elements showing all the similar songs. I just used jquery and ajax to accomplish this.
{ % raw % }
$('#training_button').click(function() {
event.preventDefault(); // prevent page from reloading
$.ajax({
url: '/get_songs',
type: 'POST',
data: JSON.stringify({
// user input stuff here
}),
contentType: 'application/json',
success: function(response) {
const similarSongs = response.assignment;
// the function returns a list of similar songs in similarity order
let resultHTML = "";
// loop through the list and create a new line for each item
for (let i = 0; i < similarSongs.length; i++) {
resultHTML += '<p>' + similarSongs[i] + '</p>';
}
// display the result
$('#assignment').html(resultHTML);
},
error: function(error) {
$('#assignment').text('Error running training: ' + str(error));
}
});
});
{ % endraw % }
As a whole, the state of the site as of now was very simple and pretty uninspiring, but making it look nice was something for later.

Improving User Friendliness
While styling using CSS is an important step, there were other things I wanted to address before starting that.
Genre Re-Arrangement
I switched the “select” input element to a group of “optgroup” elements, allowing for users to select more specific sub-genres, rather than entire large genres. This ended up providing the best balance of specificity in recommendations and not overwhelming the user with options.

To improve readability and make it easier to manage them, I also ended up moving the genre definitions into app.py
.
GENRE_OPTIONS = { # (truncated)
"Pop": ["Pop"],
"Heavy Rock": ["Hard Rock", "Metal", "Grindcore"],
"World": ["World Music"],
# ...
}
<select id="genre" name="genre">
<!-- iterate over the keys (groups of genres) -->
<!-- and values (individual genres) and put them in the optgroups and options -->
{% for group, options in genres.items() %}
<optgroup label="{{ group }}">
{% for option in options %}
<option value="{{ option }}">{{ option }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
Slider Adjustments
For the common user, some of the audio feature names would not have been easy to understand at first glance (i.e. “valence”); there also was confusion on what 0 and 100 meant for each. This led me to add two design improvements:
Adjusted slider labels. I changed each slider’s label to be a more user friendly, concise description of each feature. For example, “valence” was now “Mood.”
The addition of captions for each slider. For every feature slider, I added in a “caption” which describes what the current slider value meant, changing as the input was modified via event listeners.
{ % raw % }
const sliders = document.querySelectorAll(".slider");
const sliderFeedbacks = document.querySelectorAll(".slider-feedback");
const sliderFeedbacksText = {
"danceability": ["Chill", "Dancey"],
"energy": ["Calm", "Hype"],
"loudness": ["Quiet", "Loud"],
"speechiness": ["Musical", "Talky"],
"acousticness": ["Electronic", "Acoustic"],
"instrumentalness": ["Vocals", "No Vocals"],
"liveness": ["Studio", "Live"],
"valence": ["Negative", "Positive"]
}
// for changing slider "captions" based on value
sliders.forEach((slider, index) => {
const update = () => {
feedbackText = sliderFeedbacksText[slider.id][slider.value <= 0.5 ? 0 : 1]
sliderFeedbacks[index].textContent = feedbackText;
}
update(); // on page load
slider.addEventListener('input', update); // on slider change
});
{ % endraw % }
Three Recommendations at a Time
Originally when the submit button was pressed, the site would list every recommended song, which could be too much at a time for the user.
I updated the POST submit request to instead store every recommendation in an array, only showing the first three (most similar) song recommendations.
Then I added a “More!” button which, when clicked, would cycle to the next three recommendations in the array. The button would hide itself when it ran out of extra songs.
{ % raw % }
$(document).ready(() => {
let similarSongs; // updated when recommendation is submitted
let currentIndex = 0; // similarSongs index to start at when fetching 3 recs
/**
* This function fetches the next 3 items in similarSongs starting
* from startIndex.
* The more-button is hidden if there are no songs left to iterate through.
*/
function updateResultText(startIndex) {
let resultHTML = "";
// loop through the list and create a new line for each item
for (let i = startIndex; i < 3 + startIndex; i++) {
if (similarSongs[i]) {
resultHTML += '<p>' + similarSongs[i] + '</p>';
$('#more-button').show();
} else {
$('#more-button').hide(); // when we run out of songs,
// don't show undefined, and hide the more button
break;
}
}
$('#assignment').html(resultHTML);
}
// script for handling when the submit button is pressed
$('#submit-button').click(() => {
event.preventDefault(); // prevent page from reloading
$.ajax({
url: '/get_songs',
type: 'POST',
data: JSON.stringify({
// user inputs
}),
contentType: 'application/json',
success: function(response) {
similarSongs = response.assignment;
// the function returns a list of similar songs in similarity order
currentIndex = 0;
updateResultText(currentIndex);
},
error: function(error) {
$('#assignment').text('Error running training: ' +
JSON.stringify(error)
);
}
});
});
// script for when more recommendations are asked for
$('#more-button').click(() => {
currentIndex += 3;
updateResultText(currentIndex);
});
});
{ % endraw % }
Value Presets
Users might still be overwhelmed by the number of inputs there are, so to remedy this, I added in a new dropdown with presets, which automatically set every slider to hard coded values for their selection.
I defined the options in the app.py
file:
PRESETS = [
"Studying",
"Partying",
"Raving",
"Chill",
"Workout",
"Rainy Day",
"Road Trip",
"Beach Vibes",
"Gaming"
]
And the actual values in the JavaScript part of the page template:1
{ % raw % }
const PRESETS = {
"Studying": [20, 30, 30, 0, 70, 100, 10, 50],
"Partying": [85, 75, 70, 20, 30, 20, 30, 90],
"Raving": [95, 100, 95, 10, 10, 40, 20, 70],
"Chill": [25, 25, 30, 15, 80, 60, 15, 60],
"Workout": [90, 100, 80, 20, 15, 30, 30, 75],
"Rainy Day": [25, 30, 40, 15, 85, 70, 20, 30],
"Road Trip": [65, 70, 70, 30, 50, 40, 35, 80],
"Beach Vibes": [50, 45, 50, 20, 75, 60, 25, 70],
"Gaming": [70, 80, 80, 10, 30, 80, 20, 65]
};
{ % endraw % }
Styling
For styling, I used Bootstrap. Having previously been exposed to it a bit in work environments, I liked how much easier and organized it was to set up more complicated web layouts using it. Still, I had a lot to learn.
Ultimately, for the site layout, I went with a centered two column layout for the sliders/recommendations.

Integrating Bootstrap
I started by designing the most complicated part of the site - the two column layout containing the sliders and recommendation box.
To start, I will break down how I designed the sliders. Essentially, it’s a 3x3 grid in one column of a container.
<div class="container m-5 mx-auto">
<div class="row">
<div class="col-md-6 px-2">
<div class="row">
{% for slider, attributes in sliders.items() %}
{% if loop.index0 % 3 == 0 and not loop.first %}
</div><div class="row justify-content-center">
{% endif %}
<div class="col-md-4 mb-1">
<div class="preference-input" id="{{ slider }}-bg">
<p>{{ attributes.display_name }}</p>
<input type="range" id="{{ slider }}" class="slider form-range" min="0" max="1" step="0.01" value="0.5" required>
<p class="slider-feedback">...</p>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- right side: recommendations -->
<!-- ... -->
</div>
</div>
Each “element” in the is wrapped in a container
element. There, I added in the row
elements, and finally the col
elements.
For the container, I have two class name attributes:
m-5
, which sets a large margin around the element. All preset values like this are defined by the window size, so by default it adjusts based on if you’re viewing on a desktop or phone.mx-auto
, which horizontally centers it
Now going one level in, is a row
element (it’s a 1x2 grid, so only one row
is defined). Then, I place the slider group into the first col
.
Next is where things get more complicated.
<div class="row justify-content-center">
{% for slider, attributes in sliders.items() %}
{% if loop.index0 % 3 == 0 and not loop.first %}
</div><div class="row justify-content-center">
{% endif %}
<div class="col-md-4 mb-1">
<div class="preference-input" id="{{ slider }}-bg">
<p>{{ attributes.display_name }}</p>
<input type="range" id="{{ slider }}" class="slider form-range" min="0" max="1" step="0.01" value="0.5" required>
<p class="slider-feedback">...</p>
</div>
</div>
{% endfor %}
</div>
The sliders have to be added in using Flask’s html “notation.” There are eight sliders, so I wanted to add in three rows, with up to three sliders each. Every time we hit a third slider, I end that row then begin a new one.
An Addendum on Bootstrap’s Grids
One of the biggest things I learned with Bootstrap’s grid layouts is that each column takes up 12 “units” horizontally.
Note how the class name of the column is col-md-4
, this represents a column with a horizontal margin of 4 units, so with 3 columns with no side margin we take up 12 units.
If I changed the column side margins to be 1, that makes the horizontal margin of each column 5 units (15 total); any elements that “overflow” above that 12 unit threshold get thrown into the next row.

Not great!
Slider Backgrounds
And lastly, a final touch I wanted to throw in. Higher values would mean a “hotter” background color, while lower values meant a “cooler” color. I took the value of each slider, and used it to compute the red and blue values for the background.
{ % raw % }
const updateSliderColor = (slider, index) => {
const red = (128 * (slider.value) + 127);
const blue = (128 * (1 - slider.value) + 127);
const box = document.getElementById(slider.id + "-bg");
box.style.backgroundColor = `rgba(${red}, 200, ${blue})`;
}
{ % endraw % }

Conclusion
At this point, I now had a fully working frontend, styled to not only be easily usable on computers, but on phones too. It linked up to a ML script which trains a model on the relevant music data, looking for the closest matches to the user’s preferences.
Particularly on the ML side, some things didn’t go as expected. The song recommendations for some genres were samey, even regardless of the user’s input. In part, I believe it’s a mix of the data from Spotify just being similar, as well as integrating too many audio features on the site/training - see the curse of dimensionality.
My first approach to tackling this issue would be to reduce the number of options available (per genre or overall), and focus on the ones that best separate the songs, using principal component analysis. However, this has the downside of taking control away from the user.
That all said, I now understand the ML setup, training, and post-processing steps a lot better. Handling data properly is an important part of the workflow, and can greatly affect the effectiveness of a model.
Finally, I became much more comfortable with how Flask integrates HTML and Python. I found it really nice how lightweight apps built in it are, compared to other popular options such as React and Angular. It is also great how tightly integrated with Python and its libraries it is.
And while it took me some time to get adjusted to, I enjoyed learning how to best use Bootstrap to easily design dynamic, responsive web layouts.
It would have been extremely complicated to retrieve the specific array values from the Python value, so I stuck with mapping them to the values in the JS. ↩︎