Reverse all tracks
19 | > 20 | ); 21 | }; 22 | 23 | export default FilterReverse; 24 | -------------------------------------------------------------------------------- /tools/copy-index.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | const rootDestinations = ["sort", "compare", "tools", "about"]; 4 | 5 | // Create a 404 fallback for GitHub Pages 6 | fs.copyFile("build/index.html", `build/404.html`, (err) => { 7 | if (err) throw err; 8 | console.log(`Copied build/index.html to build/404.html`); 9 | }); 10 | 11 | // Copy index.html to path folders to be served by GitHub Pages 12 | rootDestinations.forEach((dest) => { 13 | const fullDestination = `build/${dest}`; 14 | if (!fs.existsSync(fullDestination)) { 15 | fs.mkdirSync(fullDestination); 16 | } 17 | fs.copyFile("build/index.html", `${fullDestination}/index.html`, (err) => { 18 | if (err) throw err; 19 | console.log(`Copied build/index.html to ${fullDestination}/index.html`); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/pages/Tools/FilterDistinct.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { FilterFunctionProps } from "./filter"; 3 | import { TrackWithAudioFeatures } from "../../models/Spotify"; 4 | 5 | interface IProps extends FilterFunctionProps {} 6 | 7 | const filter = (tracks: TrackWithAudioFeatures[]): TrackWithAudioFeatures[] => 8 | tracks.filter((value, index, self) => self.findIndex((t) => t.id === value.id) === index); 9 | 10 | const FilterDistinct: React.FunctionComponentRemove duplicate songs
20 | > 21 | ); 22 | }; 23 | 24 | export default FilterDistinct; 25 | -------------------------------------------------------------------------------- /src/hooks/NavigatorOnline.ts: -------------------------------------------------------------------------------- 1 | // Modified Source: https://github.com/oieduardorabelo/use-navigator-online 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | function useNavigatorOnline() { 6 | let [isOnline, setIsOnline] = useState(window.navigator.onLine); 7 | 8 | useEffect(() => { 9 | function handleOnlineStatusChange(event: Event) { 10 | setIsOnline(window.navigator.onLine); 11 | } 12 | 13 | window.addEventListener("online", handleOnlineStatusChange); 14 | window.addEventListener("offline", handleOnlineStatusChange); 15 | 16 | return () => { 17 | window.removeEventListener("online", handleOnlineStatusChange); 18 | window.removeEventListener("offline", handleOnlineStatusChange); 19 | }; 20 | }, []); 21 | 22 | return isOnline; 23 | } 24 | 25 | export default useNavigatorOnline; 26 | -------------------------------------------------------------------------------- /src/hooks/WindowSize.ts: -------------------------------------------------------------------------------- 1 | // Modified Source: https://github.com/rehooks/window-size 2 | 3 | import { useState, useEffect } from "react"; 4 | 5 | interface WindowDimensions { 6 | innerHeight: number; 7 | innerWidth: number; 8 | outerHeight: number; 9 | outerWidth: number; 10 | } 11 | 12 | function getSize(): WindowDimensions { 13 | return { 14 | innerHeight: window.innerHeight, 15 | innerWidth: window.innerWidth, 16 | outerHeight: window.outerHeight, 17 | outerWidth: window.outerWidth 18 | }; 19 | } 20 | 21 | export default function useWindowSize(): WindowDimensions { 22 | let [windowSize, setWindowSize] = useStateRandomise song order
26 | > 27 | ); 28 | }; 29 | 30 | export default FilterRandomise; 31 | -------------------------------------------------------------------------------- /src/components/MetaTags.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import useMetaTags from "react-metatags-hook"; 3 | import BannerImage from "../img/banner.png"; 4 | import config from "../config"; 5 | 6 | interface IProps { 7 | titlePrefix?: string; 8 | description: string; 9 | route: string; 10 | } 11 | 12 | const MetaTags: React.FunctionComponent{token.expiry.toLocaleTimeString()}
40 | {user.display_name}
43 |
47 | {publicPlaylistCount} public and {privatePlaylistCount} private
48 |
49 | {tracksStored}
52 | | Position | 48 |Title | 49 |50 | Artist(s) 51 | | 52 |
|---|---|---|
| {index + 1} | 58 |{track.name} | 59 |60 | {track.artists.map((a) => a.name).join(", ")} 61 | | 62 |
4 |
5 | Create emotionally gradiented Spotify playlists and more.
7 | 8 | 9 | ## 🧪 Development Setup 10 | 11 | 1. Clone the repo 12 | 2. Execute `npm install` 13 | 3. Create a new app / client id at [developer.spotify.com](https://developer.spotify.com/dashboard/applications). 14 | 4. Copy your client id into `/src/config.ts`. 15 | 5. Click "Edit Settings" in the newly created Spotify developer app and add a redirect URI to where `/spotify-authorization` will be hosted - by default this will be `https://localhost:3000/spotify-authorization` 16 | 6. Set `HTTPS=true` (HTTPS is required to use `window.crypto`) 17 | - In PowerShell: `$env:HTTPS = "true"` 18 | 7. Set `NODE_OPTIONS=--openssl-legacy-provider` (react-scripts doesn't play nice with newer versions of Node) 19 | - In PowerShell: ` $env:NODE_OPTIONS = "--openssl-legacy-provider"` 20 | 8. Execute `npm start` 21 | 9. Accept the SSL warning 22 | 23 | ## 📷 Snippets From the Web App 24 | 25 | Example Sort Visualisation of a Personal Playlist 26 |  27 | 28 | Example Comparison Visualisation of a Personal Playlists 29 |  30 | 31 | Example of Applying Filters to Playlists 32 |  33 | 34 | ## 📝 Features 35 | 36 | - **Spotify authorization for library access** 37 | - **Sort a playlist by valence and energy** - Sorting on these two values can create a transition from sadder/slower songs to more happy/energetic songs. - Can change the sorting audio features and sorting method - Exports to a new playlist 38 | - **Compare playlists** - Compare multiple playlists in 1D, 2D or 7D from selected audio features. 39 | - **Playlist tools** - Add playlists and apply filters and functions to playlists to manipulate song ordering - Exports to a new playlist 40 | 41 | > All [audio features](https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-features/) used are pre-computed by Spotify and obtained through their API. 42 | 43 | ## ❓ Why? 44 | 45 | Emotionify is an application I had thought about for a few years after doing a project at university on attempting to detect emotion in music and portraying it in an interactive environment. 46 | 47 | I was curious how the method implemented would play out with music I listen to every day and wanted some extra tools for Spotify playlists. 48 | 49 | Emotionify is not 100% accurate as emotion is highly opinion based and the values used to sort songs are averages over the whole song. This tool however does give insight on how well a computer can plot an emotional gradient with a list of songs. 50 | -------------------------------------------------------------------------------- /src/models/Spotify.ts: -------------------------------------------------------------------------------- 1 | export interface SpotifyData { 2 | user: SpotifyApi.UserObjectPrivate | undefined; 3 | playlists: { 4 | [key: string]: PlaylistObjectSimplifiedWithTrackIds; 5 | }; 6 | tracks: { 7 | [key: string]: TrackWithAudioFeatures; 8 | }; 9 | } 10 | 11 | export interface Token { 12 | value: string; 13 | expiry: Date; 14 | } 15 | 16 | export interface PlaylistObjectSimplifiedWithTrackIds extends SpotifyApi.PlaylistObjectSimplified { 17 | track_ids: string[]; 18 | } 19 | 20 | export interface TrackWithAudioFeatures extends SpotifyApi.TrackObjectFull { 21 | audio_features: SpotifyApi.AudioFeaturesObject | null | undefined; // undefined when none have been requested, null when they don't exist (after requesting) 22 | } 23 | 24 | export interface AudioFeatureProperty { 25 | key: keyof SpotifyApi.AudioFeaturesObject; 26 | min: number | undefined; 27 | max: number | undefined; 28 | show_in_sort: boolean; 29 | show_in_compare_radar: boolean; 30 | } 31 | 32 | export const availableTrackAudioFeatures: { [key: string]: AudioFeatureProperty } = { 33 | Acousticness: { 34 | key: "acousticness", 35 | min: 0, 36 | max: 1, 37 | show_in_sort: true, 38 | show_in_compare_radar: true 39 | }, 40 | Danceability: { 41 | key: "danceability", 42 | min: 0, 43 | max: 1, 44 | show_in_sort: true, 45 | show_in_compare_radar: true 46 | }, 47 | Duration: { 48 | key: "duration_ms", 49 | min: 0, 50 | max: undefined, 51 | show_in_sort: true, 52 | show_in_compare_radar: false 53 | }, 54 | Energy: { 55 | key: "energy", 56 | min: 0, 57 | max: 1, 58 | show_in_sort: true, 59 | show_in_compare_radar: true 60 | }, 61 | Instrumentalness: { 62 | key: "instrumentalness", 63 | min: 0, 64 | max: 1, 65 | show_in_sort: true, 66 | show_in_compare_radar: true 67 | }, 68 | Key: { 69 | key: "key", 70 | min: undefined, 71 | max: undefined, 72 | show_in_sort: false, 73 | show_in_compare_radar: false 74 | }, 75 | Liveness: { 76 | key: "liveness", 77 | min: 0, 78 | max: 1, 79 | show_in_sort: true, 80 | show_in_compare_radar: true 81 | }, 82 | Loudness: { 83 | key: "loudness", 84 | min: undefined, 85 | max: 0, 86 | show_in_sort: true, 87 | show_in_compare_radar: false 88 | }, 89 | Mode: { 90 | key: "mode", 91 | min: 0, 92 | max: 1, 93 | show_in_sort: false, 94 | show_in_compare_radar: false 95 | }, 96 | Speechiness: { 97 | key: "speechiness", 98 | min: 0, 99 | max: 1, 100 | show_in_sort: true, 101 | show_in_compare_radar: true 102 | }, 103 | Tempo: { 104 | key: "tempo", 105 | min: undefined, 106 | max: undefined, 107 | show_in_sort: true, 108 | show_in_compare_radar: false 109 | }, 110 | "Time Signature": { 111 | key: "time_signature", 112 | min: 0, 113 | max: undefined, 114 | show_in_sort: false, 115 | show_in_compare_radar: false 116 | }, 117 | Valence: { 118 | key: "valence", 119 | min: 0, 120 | max: 1, 121 | show_in_sort: true, 122 | show_in_compare_radar: true 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /src/components/TokenRefreshWarning.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { navigate } from "hookrouter"; 3 | import { Button, Modal } from "react-bootstrap"; 4 | import { Token } from "../models/Spotify"; 5 | 6 | interface IProps { 7 | token: Token | undefined; 8 | onLogOut: () => void; 9 | } 10 | 11 | const warningMilliseconds = 5 * 60 * 1000; 12 | 13 | const TokenRefreshWarning: React.FunctionComponent| Moved | 62 |Title | 63 |64 | Artist(s) 65 | | 66 |{x_audio_feature_name} | 67 |{y_audio_feature_name} | 68 |
|---|---|---|---|---|
| 82 | {track.index.before - track.index.after} 83 | | 84 |{track.name} | 85 |86 | {track.artists.map((a) => a.name).join(", ")} 87 | | 88 |89 | {track.audio_features !== undefined && 90 | track.audio_features !== null && 91 | track.audio_features[x_audio_feature]} 92 | | 93 |94 | {track.audio_features !== undefined && 95 | track.audio_features !== null && 96 | track.audio_features[y_audio_feature]} 97 | | 98 |
24 | Easily create emotionally gradiented Spotify playlists for smoother emotional 25 | transitions in your listening 26 |
27 |41 | Using features calculated by Spotify for each song, sort your playlist on an emotional 42 | gradient. 43 |
44 |45 | You can also change how and what your songs are sorted by to explore different methods 46 | of sorting playlists and discover new ways to listen to your playlists. 47 |
48 |66 | Compare your playlists based off audio features calculated by Spotify. 67 |
68 |69 | Select any number of playlists and compare them in one or two dimensions for any audio 70 | feature or seven dimensions for specific audio features. 71 |
72 |90 | Merge, filter and sort your playlists to make a more focused playlist. 91 |
92 |93 | Select playlists, filter and sort by audio features and even randomise your playlists 94 | to make a playlist focused for any occasion. 95 |
96 |Loading...
); 20 | 21 | useEffect(() => { 22 | const params = new URLSearchParams(window.location.search); 23 | const code = params.get("code"); 24 | const error = params.get("error"); 25 | const redirectUri = `${window.location.origin}${window.location.pathname}`; // Don't want this to include the code/error coming back (so no window.location.href) 26 | 27 | if (error) { 28 | setMessage(Authorization failed: {error}
); 29 | return; 30 | } 31 | 32 | if (code === null) { 33 | // No code, initiate authorization request 34 | const codeVerifier = randomString(64); 35 | localStorage.setItem(localStorageCodeVerifierKey, codeVerifier); 36 | 37 | crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)).then((buffer) => { 38 | const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(buffer))) 39 | .replace(/\+/g, "-") 40 | .replace(/\//g, "_") 41 | .replace(/=+$/, ""); 42 | 43 | // Redirect 44 | const authUrl = `https://accounts.spotify.com/authorize?${encodeData({ 45 | client_id: config.spotify.clientId, 46 | response_type: "code", 47 | redirect_uri: redirectUri, 48 | code_challenge_method: "S256", 49 | code_challenge: codeChallenge, 50 | scope: config.spotify.permissionScope 51 | })}`; 52 | window.location.href = authUrl; 53 | }); 54 | 55 | setMessage( 56 | <> 57 |Redirecting to Spotify...
59 | > 60 | ); 61 | } else { 62 | // Pull out the code verifier we stored previously 63 | const codeVerifier = localStorage.getItem(localStorageCodeVerifierKey); 64 | localStorage.removeItem(localStorageCodeVerifierKey); 65 | if (codeVerifier === null) { 66 | setMessage(Missing code verifier
); 67 | return; 68 | } 69 | 70 | // Exchange code for access token (after authorization request comes back) 71 | fetch("https://accounts.spotify.com/api/token", { 72 | method: "POST", 73 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 74 | body: encodeData({ 75 | client_id: config.spotify.clientId, 76 | grant_type: "authorization_code", 77 | code, 78 | redirect_uri: redirectUri, 79 | code_verifier: codeVerifier 80 | }) 81 | }) 82 | .then((res) => res.json()) 83 | .then((data) => { 84 | if (data.access_token && data.expires_in) { 85 | // Store token 86 | const expiryDate = new Date(); 87 | expiryDate.setSeconds(expiryDate.getSeconds() + data.expires_in); 88 | onTokenChange({ value: data.access_token, expiry: expiryDate }); 89 | 90 | // Redirect back to where we came from in the application 91 | let local_redirect = localStorage.getItem(localStorageRedirectKey); 92 | if (local_redirect) { 93 | localStorage.removeItem(localStorageRedirectKey); 94 | navigate(local_redirect); 95 | } else { 96 | navigate("/"); 97 | } 98 | 99 | setMessage( 100 | <> 101 |Authorization successful
103 | > 104 | ); 105 | } else { 106 | setMessage(Failed to retrieve access token
); 107 | } 108 | }) 109 | .catch(() => setMessage(Error fetching token
)); 110 | } 111 | }, []); 112 | 113 | return ( 114 |52 | Select playlists and compare them on one audio feature, two audio features or seven 53 | pre-selected audio features. 54 |
55 |65 | To get access to your playlists and the ability to create playlists, you need to sign 66 | into Spotify. 67 |
68 ||
120 | {/* Spotify has recently changed their responses - playlist.images is now nullable */}
121 | {playlist.images !== null && playlist.images.length > 0 && (
122 | |
129 |
130 | {playlist.name}
131 |
132 |
140 | |
141 |
11 | Emotionify is an application I had thought about for a few years after doing a project 12 | at university on attempting to detect emotion in music and portraying it in an 13 | interactive environment. 14 |
15 |
16 | By default, choosing a playlist on the Sort page will sort your music by{" "}
17 | Valence and Energy which are{" "}
18 |
19 | audio features calculated by Spotify
20 |
21 | . When researching my project, this is was some of the best public and easily accessible
22 | data that related to emotion in music. Also with Spotify being so large and active among
23 | many people, it allows people to easily organise their own playlists.
24 |
26 | Emotionify is not 100% accurate as emotion is highly opinion based and the values used 27 | to sort songs are averages over the whole song. This tool however does give insight on 28 | how well a computer can plot an emotional gradient with a list of songs. 29 |
30 |31 | I made this project because I was curious if this data really did mean anything and if 32 | my guess of the data being able to be used to sort playlists by emotion was correct. I 33 | can leave that up to you to decide! 34 |
35 |36 | As you can see on the Sort page, I have also made the other audio features available for 37 | you to play around with. 38 |
39 |40 | You can find other projects and tutorials made by me on my site{" "} 41 | nitratine.net. 42 |
43 | 44 |
47 | By default, choosing a playlist on the Sort page will sort your music by{" "}
48 | Valence and Energy which I had used in a previous project to
49 | detect vague emotion of songs.
50 |
53 | Yes, if the sort functionality didn't remove duplicate songs they would be bunched up by 54 | each other. Removing these allows for proper transitions into a different song. 55 |
56 |58 | When you log in, a token is stored on your machine that's used to ask Spotify for your 59 | playlists (otherwise we couldn't see them). This token only lasts for an hour, meaning 60 | if you use this site for more than an hour at a time, you will need to authorise the 61 | application again. 62 |
63 |65 | All data received from Spotify is stored locally on your machine; none of this is sent 66 | off to some other server (apart from Spotify itself). You can view a summary of your 67 | stored data by clicking on your name in the top right when logged in. 68 |
69 |71 | All data is stored in localStorage on your machine; simply logging out will clear all 72 | this data. 73 |
74 |76 | We look for your playlists when you log in. If you want to use a playlist you 77 | created/added after you logged into this site, log out and then log back in to get the 78 | new playlist. 79 |
80 |82 | Here are some summaries from{" "} 83 | 84 | Spotify 85 | 86 | : 87 |
88 |Acousticness: A confidence measure from 0 to 1 of whether the track is
91 | acoustic. 1 represents high confidence the track is acoustic.
92 | Danceability: Danceability describes how suitable a track is for dancing
95 | based on a combination of musical elements. A value of 0 is least danceable and 1 is
96 | most danceable.
97 | Duration: The duration of the track in milliseconds.
100 | Energy: Energy is a measure from 0 to 1 and represents a perceptual
103 | measure of intensity and activity. Typically, energetic tracks feel fast, loud, and
104 | noisy. For example, death metal has high energy, while a Bach prelude scores low on
105 | the scale.
106 | Instrumentalness: Predicts whether a track contains no vocals. The closer
109 | the instrumentalness value is to 1, the greater likelihood the track contains no vocal
110 | content. Values above 0.5 are intended to represent instrumental tracks, but
111 | confidence is higher as the value approaches 1.
112 | Key: The estimated overall key of the track. (-1 if no key is detected)
115 | Liveness: Detects the presence of an audience in the recording. Higher
118 | liveness values represent an increased probability that the track was performed live.
119 | A value above 0.8 provides strong likelihood that the track is live.
120 | Loudness: The overall loudness of a track in decibels (dB). Loudness
123 | values are averaged across the entire track and are useful for comparing relative
124 | loudness of tracks. Values typical range between -60 and 0 db.
125 | Mode: Mode indicates the modality (major or minor) of a track, the type
128 | of scale from which its melodic content is derived. Major is represented by 1 and
129 | minor is 0.
130 | Speechiness: Speechiness detects the presence of spoken words in a track.
133 | Talk shows and audio books are closer to 1, songs made entirely of spoken words are
134 | above 0.66, songs that contain both music and speech are typically around 0.33 - 0.66
135 | and values below 0.33 represent music and other non-speech-like tracks.
136 | Tempo: The overall estimated tempo of a track in beats per minute (BPM).
139 | Time Signature: An estimated overall time signature of a track. The time
142 | signature (meter) is a notational convention to specify how many beats are in each bar
143 | (or measure).
144 | Valence: A measure from 0 to 1 describing the musical positiveness
147 | conveyed by a track. Tracks with high valence sound more positive, while tracks with
148 | low valence sound more negative.
149 | 102 | Apply filters and functions to manipulate your playlists. 103 |
104 |114 | To get access to your playlists and the ability to create playlists, you need to sign 115 | into Spotify. 116 |
117 |144 | Here you can select a playlist and look at how the new playlist is sorted. You can then 145 | create the new playlist or select a different playlist. 146 |
147 |157 | To get access to your playlists and the ability to create playlists, you need to sign 158 | into Spotify. 159 |
160 |