├── .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 |
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 |
--------------------------------------------------------------------------------