├── .gitignore ├── demoImg.png ├── main.py ├── loadLyrics.py ├── .lyrics └── lyrics.lrc ├── README.md ├── waves.py ├── spotifyClient.py └── streamMusic.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | 3 | .env 4 | oauth.json 5 | *.mp3 6 | .cache -------------------------------------------------------------------------------- /demoImg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PandaBean18/muCLI/HEAD/demoImg.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | from ytmusicapi import YTMusic, OAuthCredentials 4 | import streamMusic 5 | 6 | load_dotenv() 7 | 8 | client_secret = os.getenv('client_secret') 9 | client_id = os.getenv('client_id') 10 | 11 | ytmusic = YTMusic('oauth.json', oauth_credentials=OAuthCredentials(client_id=client_id, client_secret=client_secret)) 12 | 13 | user_inp = input("Which song do you want to listen to?\n> ") 14 | 15 | search_result = ytmusic.search(user_inp) 16 | top_result_video_id = search_result[0]["videoId"] 17 | uri = f"https://youtu.be/{top_result_video_id}" 18 | total_time = int(search_result[0]["duration_seconds"]) 19 | title = search_result[0]["title"] 20 | artists = "" 21 | 22 | for a in search_result[0]['artists']: 23 | if (artists == ""): 24 | artists += a['name'] 25 | else: 26 | artists += f", {a['name']}" 27 | 28 | streamMusic.play_and_rewrite(uri, total_time, title, artists) 29 | -------------------------------------------------------------------------------- /loadLyrics.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | def get_lyrics(spotify_id): 3 | subprocess.Popen("syrics "+spotify_id, shell=True,stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT).wait() 4 | 5 | def download_lyrics(track_name): 6 | import spotifyClient 7 | s = spotifyClient.get_spotify_url(track_name) 8 | if (s == ""): 9 | return False 10 | 11 | get_lyrics(s) 12 | return True 13 | 14 | def load_lyrics_to_dict(d): 15 | f = open("./.lyrics/lyrics.lrc", "r") 16 | i = 0 17 | while i < 4: 18 | current = f.readline() 19 | i += 1 20 | 21 | while 1: 22 | current = f.readline() 23 | 24 | if (current == ""): 25 | break 26 | 27 | s = current.find("[") 28 | e = current.find("]") 29 | 30 | if (s == -1 or e == -1): 31 | return 32 | 33 | minutes = int(current[s+1:e].split(":")[0]) 34 | seconds = int(float(current[s+1:e].split(":")[1])) 35 | if (current[e+2:-1] != "♪"): 36 | d[(60*minutes)+seconds] = current[e+2:-1] 37 | -------------------------------------------------------------------------------- /.lyrics/lyrics.lrc: -------------------------------------------------------------------------------- 1 | [ti:Wallflower] 2 | [al:Wallflower] 3 | [ar:Tim Atlas] 4 | [length: 02:57.50] 5 | [00:27.93] Respect your grind 6 | [00:31.08] Working the night, the night, the night shift 7 | [00:37.04] Wallflower type 8 | [00:40.21] Electric slide, just so you know I exist 9 | [00:45.22] Let's move 10 | [00:48.73] 'Til light is shining 11 | [00:52.58] Peeking through the roof 12 | [00:54.58] No rules 13 | [00:57.75] I can't shut my eyes 14 | [01:01.40] My eyes when I'm with you 15 | [01:03.54] Just give me, give me, give me one more chance 16 | [01:08.28] I'm livin', livin', livin' for a dance 17 | [01:12.67] Just give me, give me, give me one more chance with you 18 | [01:19.65] With you 19 | [01:22.44] Tell me what you like 20 | [01:25.75] I know I'm usually not your typе 21 | [01:30.55] Bet, I checked our stars arе not aligned 22 | [01:35.10] But the vibe just feels so nice with you 23 | [01:39.87] So, let's move 24 | [01:43.68] 'Til light is shining 25 | [01:47.41] Peeking through the roof 26 | [01:49.49] No rules 27 | [01:52.65] I can't shut my eyes 28 | [01:56.12] My eyes when I'm with you 29 | [01:58.54] Just give me, give me, give me one more chance 30 | [02:03.01] I'm livin', livin', livin' for a dance 31 | [02:07.68] Just give me, give me, give me one more chance with you 32 | [02:14.60] With you 33 | [02:17.40] Tell me what you like 34 | [02:20.59] I know I'm usually not your type 35 | [02:25.33] Bet. I checked our stars are not aligned 36 | [02:29.93] But the vibe just feels so nice with you 37 | [02:35.78] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # muCLI 2 | 3 | **muCLI** is a command line program to play music that uses the youtube and spotify api to play songs along with its lyrics 4 | 5 | demo image 6 | 7 | ## API credentials 8 | 9 | The application uses both spotify and youtube credentials. You can get spotify client id and client secret by creating a new app on spotify developer console, and the youtube client id and client secret by creating a new GCP project that has youtube api enabled. 10 | The credentials need to be stored in a .env file in such manner: 11 | 12 | ```txt 13 | client_secret= 14 | client_id= 15 | SPOTIPY_CLIENT_ID= 16 | SPOTIPY_CLIENT_SECRET= 17 | ``` 18 | 19 | **Note**: it IS spotipy in the variable names, not spotify, program uses the spotipy library and I copied the variable names straight from the documentation and was too lazy to change them. 20 | 21 | ## System requirements 22 | 23 | [yt-dlp](https://github.com/yt-dlp/yt-dlp#readme) 24 | 25 | [ytmusicapi](https://ytmusicapi.readthedocs.io/en/stable/) 26 | 27 | [spotipy](https://spotipy.readthedocs.io/en/2.25.0/) 28 | 29 | [syrics](https://github.com/akashrchandran/syrics) 30 | 31 | pydub 32 | 33 | numpy 34 | 35 | mpg321 36 | 37 | ## Lyrics 38 | 39 | The application uses syrics to get the lyrics, you will need to set it up and can check how to set it up [here](https://github.com/akashrchandran/syrics). As he has mentioned, getting lyrics from spotify maybe against their TOS, this example here is for educational purposes only. 40 | -------------------------------------------------------------------------------- /waves.py: -------------------------------------------------------------------------------- 1 | from pydub import AudioSegment 2 | import numpy as np 3 | import os 4 | import time 5 | 6 | def fillMatricies(normalized_samples, m): 7 | i = 0 8 | while (i < len(normalized_samples)): 9 | current = np.zeros((41, 20)) 10 | j = 0 11 | while (j <= 20): 12 | k = 0 13 | while (k < 20 and ((k+i) < len(normalized_samples))): 14 | if (normalized_samples[k+i] - j >= 0): 15 | current[20-j][k] = 1 16 | if (j != 0): 17 | current[20+j][k] = 1 18 | k += 1 19 | j += 1 20 | 21 | i += 20 22 | m.append(current) 23 | 24 | def printFromMatricies(m): 25 | os.system("clear") 26 | for matrix in m: 27 | i = 0 28 | for row in matrix: 29 | if (i == 20): 30 | print(" \u2500\u2500\u2591", end="") 31 | else: 32 | print(" ", end="") 33 | for val in row: 34 | if (i == 20): 35 | print("\u2591", end="\u2591") 36 | elif (val == 1): 37 | print("\u2591", end=" ") 38 | else: 39 | print(" ", end=" ") 40 | 41 | if (i == 20): 42 | print("\u2500\u2500") 43 | else: 44 | print() 45 | i += 1 46 | time.sleep(0.1) 47 | os.system("clear") 48 | 49 | def createNormalizedSamples(normalized_samples, file_name): 50 | audio = AudioSegment.from_mp3(file_name) 51 | raw_data = audio.raw_data # Returns bytes 52 | 53 | samples = np.frombuffer(raw_data, dtype=np.int16) 54 | 55 | frame_rate = audio.frame_rate 56 | 57 | duration = len(samples) / (frame_rate*2) 58 | bars_per_second = 200 59 | 60 | total_bars = int(duration * bars_per_second) 61 | 62 | chunk_size = len(samples) // total_bars 63 | downsampled_samples = [np.mean(np.abs(samples[i * chunk_size : (i + 1) * chunk_size])) for i in range(total_bars)] 64 | 65 | max_amplitude = max(downsampled_samples) 66 | console_height = 12 67 | s = [int((sample / max_amplitude) * console_height) for sample in downsampled_samples] 68 | 69 | for x in s: 70 | normalized_samples.append(x) 71 | 72 | 73 | # display_chunks(normalized_samples) 74 | # m = [] 75 | # fillMatricies(normalized_samples, m) 76 | # printFromMatricies(m) 77 | -------------------------------------------------------------------------------- /spotifyClient.py: -------------------------------------------------------------------------------- 1 | import spotipy 2 | from spotipy.oauth2 import SpotifyClientCredentials 3 | import os 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | def get_spotify_url(track_name): 9 | auth_manager = SpotifyClientCredentials(os.getenv("SPOTIPY_CLIENT_ID"), os.getenv("SPOTIPY_CLIENT_SECRET")) 10 | sp = spotipy.Spotify(auth_manager=auth_manager) 11 | results = sp.search(q=track_name, type='track') 12 | uri = results["tracks"]["items"][0]["external_urls"]["spotify"] 13 | 14 | return uri 15 | 16 | #{'album': {'album_type': 'album', 'artists': [{'external_urls': {'spotify': 'https://open.spotify.com/artist/7Ln80lUS6He07XvHI8qqHH'}, 'href': 'https://api.spotify.com/v1/artists/7Ln80lUS6He07XvHI8qqHH', 'id': '7Ln80lUS6He07XvHI8qqHH', 'name': 'Arctic Monkeys', 'type': 'artist', 'uri': 'spotify:artist:7Ln80lUS6He07XvHI8qqHH'}], 'available_markets': ['AR', 'AU', 'AT', 'BE', 'BO', 'BR', 'BG', 'CL', 'CO', 'CR', 'CY', 'CZ', 'DK', 'DO', 'DE', 'EC', 'EE', 'SV', 'FI', 'FR', 'GR', 'GT', 'HN', 'HK', 'HU', 'IS', 'IE', 'IT', 'LV', 'LT', 'LU', 'MY', 'MT', 'MX', 'NL', 'NZ', 'NI', 'NO', 'PA', 'PY', 'PE', 'PH', 'PL', 'PT', 'SG', 'SK', 'ES', 'SE', 'CH', 'TW', 'TR', 'UY', 'GB', 'AD', 'LI', 'MC', 'ID', 'JP', 'TH', 'VN', 'RO', 'IL', 'ZA', 'SA', 'AE', 'BH', 'QA', 'OM', 'KW', 'EG', 'MA', 'DZ', 'TN', 'LB', 'JO', 'PS', 'IN', 'KZ', 'MD', 'UA', 'AL', 'BA', 'HR', 'ME', 'MK', 'RS', 'SI', 'KR', 'BD', 'PK', 'LK', 'GH', 'KE', 'NG', 'TZ', 'UG', 'AG', 'AM', 'BS', 'BB', 'BZ', 'BT', 'BW', 'BF', 'CV', 'CW', 'DM', 'FJ', 'GM', 'GE', 'GD', 'GW', 'GY', 'HT', 'JM', 'KI', 'LS', 'LR', 'MW', 'MV', 'ML', 'MH', 'FM', 'NA', 'NR', 'NE', 'PW', 'PG', 'WS', 'SM', 'ST', 'SN', 'SC', 'SL', 'SB', 'KN', 'LC', 'VC', 'SR', 'TL', 'TO', 'TT', 'TV', 'VU', 'AZ', 'BN', 'BI', 'KH', 'CM', 'TD', 'KM', 'GQ', 'SZ', 'GA', 'GN', 'KG', 'LA', 'MO', 'MR', 'MN', 'NP', 'RW', 'TG', 'UZ', 'ZW', 'BJ', 'MG', 'MU', 'MZ', 'AO', 'CI', 'DJ', 'ZM', 'CD', 'CG', 'IQ', 'LY', 'TJ', 'VE', 'ET', 'XK'], 'external_urls': {'spotify': 'https://open.spotify.com/album/1XkGORuUX2QGOEIL4EbJKm'}, 'href': 'https://api.spotify.com/v1/albums/1XkGORuUX2QGOEIL4EbJKm', 'id': '1XkGORuUX2QGOEIL4EbJKm', 'images': [{'height': 640, 'width': 640, 'url': 'https://i.scdn.co/image/ab67616d0000b273b1f8da74f225fa1225cdface'}, {'height': 300, 'width': 300, 'url': 'https://i.scdn.co/image/ab67616d00001e02b1f8da74f225fa1225cdface'}, {'height': 64, 'width': 64, 'url': 'https://i.scdn.co/image/ab67616d00004851b1f8da74f225fa1225cdface'}], 'is_playable': True, 'name': 'Favourite Worst Nightmare', 'release_date': '2007-04-22', 'release_date_precision': 'day', 'total_tracks': 12, 'type': 'album', 'uri': 'spotify:album:1XkGORuUX2QGOEIL4EbJKm'}, 'artists': [{'external_urls': {'spotify': 'https://open.spotify.com/artist/7Ln80lUS6He07XvHI8qqHH'}, 'href': 'https://api.spotify.com/v1/artists/7Ln80lUS6He07XvHI8qqHH', 'id': '7Ln80lUS6He07XvHI8qqHH', 'name': 'Arctic Monkeys', 'type': 'artist', 'uri': 'spotify:artist:7Ln80lUS6He07XvHI8qqHH'}], 'available_markets': ['AR', 'AU', 'AT', 'BE', 'BO', 'BR', 'BG', 'CL', 'CO', 'CR', 'CY', 'CZ', 'DK', 'DO', 'DE', 'EC', 'EE', 'SV', 'FI', 'FR', 'GR', 'GT', 'HN', 'HK', 'HU', 'IS', 'IE', 'IT', 'LV', 'LT', 'LU', 'MY', 'MT', 'MX', 'NL', 'NZ', 'NI', 'NO', 'PA', 'PY', 'PE', 'PH', 'PL', 'PT', 'SG', 'SK', 'ES', 'SE', 'CH', 'TW', 'TR', 'UY', 'GB', 'AD', 'LI', 'MC', 'ID', 'JP', 'TH', 'VN', 'RO', 'IL', 'ZA', 'SA', 'AE', 'BH', 'QA', 'OM', 'KW', 'EG', 'MA', 'DZ', 'TN', 'LB', 'JO', 'PS', 'IN', 'KZ', 'MD', 'UA', 'AL', 'BA', 'HR', 'ME', 'MK', 'RS', 'SI', 'KR', 'BD', 'PK', 'LK', 'GH', 'KE', 'NG', 'TZ', 'UG', 'AG', 'AM', 'BS', 'BB', 'BZ', 'BT', 'BW', 'BF', 'CV', 'CW', 'DM', 'FJ', 'GM', 'GE', 'GD', 'GW', 'GY', 'HT', 'JM', 'KI', 'LS', 'LR', 'MW', 'MV', 'ML', 'MH', 'FM', 'NA', 'NR', 'NE', 'PW', 'PG', 'WS', 'SM', 'ST', 'SN', 'SC', 'SL', 'SB', 'KN', 'LC', 'VC', 'SR', 'TL', 'TO', 'TT', 'TV', 'VU', 'AZ', 'BN', 'BI', 'KH', 'CM', 'TD', 'KM', 'GQ', 'SZ', 'GA', 'GN', 'KG', 'LA', 'MO', 'MR', 'MN', 'NP', 'RW', 'TG', 'UZ', 'ZW', 'BJ', 'MG', 'MU', 'MZ', 'AO', 'CI', 'DJ', 'ZM', 'CD', 'CG', 'IQ', 'LY', 'TJ', 'VE', 'ET', 'XK'], 'disc_number': 1, 'duration_ms': 253586, 'explicit': False, 'external_ids': {'isrc': 'GBCEL0700074'}, 'external_urls': {'spotify': 'https://open.spotify.com/track/0BxE4FqsDD1Ot4YuBXwAPp'}, 'href': 'https://api.spotify.com/v1/tracks/0BxE4FqsDD1Ot4YuBXwAPp', 'id': '0BxE4FqsDD1Ot4YuBXwAPp', 'is_local': False, 'is_playable': True, 'name': '505', 'popularity': 84, 'preview_url': None, 'track_number': 12, 'type': 'track', 'uri': 'spotify:track:0BxE4FqsDD1Ot4YuBXwAPp'} -------------------------------------------------------------------------------- /streamMusic.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pydub import AudioSegment 3 | from pydub.playback import play 4 | import time 5 | import threading 6 | import waves 7 | import loadLyrics 8 | 9 | class StoppableThread(threading.Thread): 10 | """Thread class with a stop() method. The thread itself has to check 11 | regularly for the stopped() condition.""" 12 | 13 | def __init__(self, *args, **kwargs): 14 | super(StoppableThread, self).__init__(*args, **kwargs) 15 | self._stop_event = threading.Event() 16 | 17 | def stop(self): 18 | self._stop_event.set() 19 | 20 | def stopped(self): 21 | return self._stop_event.is_set() 22 | 23 | def load_audio(file, obj): 24 | obj[0] = AudioSegment.from_file(file, "mp3") 25 | 26 | def display_data(m, d, start_time, total_time, song_title, artists): 27 | # this function will first display the waveforms and then the lyrics 28 | # to print the lyrics, check the closest lyric that has passed in terms of time elapsed 29 | # if this lyric == "" meaning no lyrics have passed, we mark the starting lyric as first one 30 | # if this lyric is not "", then we check the count of lyrics above it 31 | # if count >= 4: we print the first 4, then this, then the remaining/remaining four 32 | # if count < 4: we print till the current lyric, then 9-n 33 | 34 | os.system("clear") 35 | for matrix in m: 36 | i = 0 37 | count = 0 38 | time_elapsed = int(time.time() - start_time) 39 | n = 0 40 | for key in d.keys(): 41 | if (key > time_elapsed): 42 | break 43 | n += 1 44 | 45 | start_at = 0 46 | 47 | if (n == 0): 48 | start_at = 0 49 | elif (n == len(d)-1): 50 | start_at = n-13 51 | elif (n < 6): 52 | start_at = 0 53 | else: 54 | start_at = n-6 55 | 56 | for row in matrix: 57 | if (i < 3): 58 | i += 1 59 | continue 60 | elif (i == 20): 61 | print(" \u2500\u2500\u2591", end="") 62 | else: 63 | print(" ", end="") 64 | 65 | for val in row: 66 | if (i == 20): 67 | print("\u2591", end="\u2591") 68 | elif (val == 1): 69 | print("\u2591", end=" ") 70 | else: 71 | print(" ", end=" ") 72 | 73 | if (i == 20): 74 | print("\u2500\u2500", end=" ") 75 | 76 | if (i >= 14 and i <= 26): 77 | g = 0 78 | printed = 0 79 | for key in d.keys(): 80 | if (g >= start_at+count and count < 13): 81 | if (i != 20): 82 | print(" ", end="") 83 | if (time_elapsed > key): 84 | print(f"\033[36m {d[key]}\033[0m") 85 | else: 86 | print(" ",d[key], sep=" ") 87 | count += 1 88 | printed = 1 89 | break 90 | g += 1 91 | 92 | if (printed == 0): 93 | print() 94 | else: 95 | print() 96 | i += 1 97 | s = " " * len(song_title) 98 | print(f" \033[47m {s} \033[00m") 99 | print(f" \033[47m\033[31m {song_title} \033[00m {artists}") 100 | print(f" \033[47m {s} \033[00m\n") 101 | bar_length = 142 102 | minutes = int(time_elapsed // 60) 103 | seconds = int(time_elapsed % 60) 104 | minutes_total = int(total_time // 60) 105 | seconds_total = int(total_time % 60) 106 | formatted_elapsed_time = f"{minutes:02}:{seconds:02}" 107 | formatted_total_time = f"{minutes_total:02}:{seconds_total:02}" 108 | progress = time_elapsed / total_time if total_time > 0 else 0 109 | filled_length = int(bar_length * progress) 110 | remaining_length = bar_length - filled_length 111 | filled_bar = "\033[32m█\033[0m" * filled_length 112 | remaining_bar = "\033[37m░\033[0m" * remaining_length 113 | print(f"{formatted_elapsed_time} [{filled_bar}{remaining_bar}] {formatted_total_time}") 114 | time.sleep(0.1) 115 | os.system("clear") 116 | 117 | def play_and_rewrite(url, total_duration, title, artists): 118 | print("Loading...") 119 | v = loadLyrics.download_lyrics(title) 120 | 121 | if (not v): 122 | print("There was an error while trying to fetch lyrics from spotify.") 123 | exit(0) 124 | 125 | d = {} 126 | loadLyrics.load_lyrics_to_dict(d) 127 | os.system(f'yt-dlp -x --audio-format mp3 --postprocessor-args "-ss 0 -t 20.07" -q --no-warnings --force-overwrites -o "part1.mp3" "{url}"') 128 | thread1 = threading.Thread(target=os.system, args=(f'yt-dlp -x --audio-format mp3 -q --no-warnings --postprocessor-args "-ss 20 -t 40.07" --force-overwrites -o "part2.mp3" "{url}"',)) 129 | thread1.start() 130 | waves_normalized_samples = [] 131 | m = [] 132 | waves.createNormalizedSamples(waves_normalized_samples, "part1.mp3") 133 | waves.fillMatricies(waves_normalized_samples, m) 134 | thread2 = StoppableThread(target=display_data, args=(m,d, time.time(), total_duration, title, artists)) 135 | thread2.start() 136 | start = time.time() 137 | os.system("mpg321 -q part1.mp3") 138 | thread1.join() 139 | start_time = 60 140 | file_currently_playing = 2 141 | prev = 40 142 | 143 | while(start_time <= total_duration): 144 | output_file = f"part{file_currently_playing}.mp3" 145 | file_to_be_overwritten = 3-file_currently_playing 146 | writeFile = f"part{file_to_be_overwritten}.mp3" 147 | waves_normalized_samples = [] 148 | m = [] 149 | waves.createNormalizedSamples(waves_normalized_samples, output_file) 150 | waves.fillMatricies(waves_normalized_samples, m) 151 | thread1 = threading.Thread(target=os.system, args=(f'yt-dlp -x --audio-format mp3 -q --no-warnings --postprocessor-args "-ss {start_time} -t {prev+20}.07" --force-overwrites -o "{writeFile}" "{url}"',)) 152 | thread1.start() 153 | thread2.stop() 154 | thread2 = StoppableThread(target=display_data, args=(m,d,int(start), total_duration, title, artists)) 155 | thread2.start() 156 | os.system(f"mpg321 -q part{file_currently_playing}.mp3") 157 | thread1.join() 158 | start_time += prev + 20 159 | prev+=20 160 | file_currently_playing = file_to_be_overwritten 161 | thread2 = StoppableThread(target=display_data, args=(m,d,int(start), total_duration, title, artists)) 162 | thread2.start() 163 | os.system(f"mpg321 -q part{file_currently_playing}.mp3") 164 | --------------------------------------------------------------------------------