rhythmsync 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rhythmsync-1.0.0/PKG-INFO +110 -0
- rhythmsync-1.0.0/README.md +97 -0
- rhythmsync-1.0.0/pyproject.toml +25 -0
- rhythmsync-1.0.0/rhythmsync/__init__.py +0 -0
- rhythmsync-1.0.0/rhythmsync/main.py +411 -0
- rhythmsync-1.0.0/rhythmsync.egg-info/PKG-INFO +110 -0
- rhythmsync-1.0.0/rhythmsync.egg-info/SOURCES.txt +10 -0
- rhythmsync-1.0.0/rhythmsync.egg-info/dependency_links.txt +1 -0
- rhythmsync-1.0.0/rhythmsync.egg-info/entry_points.txt +2 -0
- rhythmsync-1.0.0/rhythmsync.egg-info/requires.txt +3 -0
- rhythmsync-1.0.0/rhythmsync.egg-info/top_level.txt +1 -0
- rhythmsync-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rhythmsync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI music player with synchronized lyrics (LRC) support
|
|
5
|
+
Author: KON/NOS R
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: music,cli,audio,lrc,player,terminal
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: pygame
|
|
11
|
+
Requires-Dist: rich
|
|
12
|
+
Requires-Dist: mutagen
|
|
13
|
+
|
|
14
|
+
# RhythmSync
|
|
15
|
+
|
|
16
|
+
CLI audio player that plays music while displaying synchronized lyrics (LRC).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- Play single audio files:
|
|
23
|
+
- Once
|
|
24
|
+
- In loop (`-r`)
|
|
25
|
+
- Play all supported audio files in a directory:
|
|
26
|
+
- Alphabetical order (`-d`)
|
|
27
|
+
- Alphabetical loop (`-dr`)
|
|
28
|
+
- Shuffle (`-ds`)
|
|
29
|
+
- Display synchronized lyrics during playback
|
|
30
|
+
- Show song metadata tags
|
|
31
|
+
- Styled terminal music player UI
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Supported Audio Formats
|
|
36
|
+
|
|
37
|
+
- `.mp3`
|
|
38
|
+
- `.flac`
|
|
39
|
+
- `.wav`
|
|
40
|
+
- `.ogg`
|
|
41
|
+
|
|
42
|
+
### Notes
|
|
43
|
+
- Other audio formats might work, but full functionality is not guaranteed.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Lyrics Sync
|
|
48
|
+
|
|
49
|
+
RhythmSync reads embedded lyric metadata from audio files.
|
|
50
|
+
|
|
51
|
+
Supported tags include:
|
|
52
|
+
- `SYLT`, `SYLT::eng`
|
|
53
|
+
- `LYRICS`, `LYRICS:eng`
|
|
54
|
+
- `LYRICS-ENG`, `LYRICS_EN`
|
|
55
|
+
- `LYRICS_SYNCED`, `SYNCEDLYRICS`
|
|
56
|
+
|
|
57
|
+
### LRC Format
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
[00:12.34] First line
|
|
61
|
+
[00:15.67] Second line
|
|
62
|
+
[00:18.90] Third line
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- Format: `[mm:ss.xx] Line`
|
|
66
|
+
|
|
67
|
+
### Notes
|
|
68
|
+
- If no lyrics are found, a placeholder message is shown.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Commands
|
|
73
|
+
|
|
74
|
+
### General
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
help - Lists all available commands.
|
|
78
|
+
clear - Clears the terminal.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
### Playback
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
play {path} - Plays a single audio file once.
|
|
86
|
+
play -r {path} - Plays a single audio file in repeat mode until stopped (Ctrl+C).
|
|
87
|
+
play -d {directory} - Plays all supported audio files in alphabetical order.
|
|
88
|
+
play -dr {directory} - Plays all supported audio files in alphabetical order and loops around until stopped (Ctrl+C).
|
|
89
|
+
play -ds {directory} - Plays all supported audio files in shuffled order.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
### Metadata
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
info {path} - Displays all available metadata.
|
|
97
|
+
info {path} [tags] - Displays only given metadata tags.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Notes
|
|
103
|
+
|
|
104
|
+
- Paths containing spaces must be wrapped in quotes:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
play "My Music/song.mp3"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- Use `Ctrl+C` to stop playback or exit the app
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# RhythmSync
|
|
2
|
+
|
|
3
|
+
CLI audio player that plays music while displaying synchronized lyrics (LRC).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Play single audio files:
|
|
10
|
+
- Once
|
|
11
|
+
- In loop (`-r`)
|
|
12
|
+
- Play all supported audio files in a directory:
|
|
13
|
+
- Alphabetical order (`-d`)
|
|
14
|
+
- Alphabetical loop (`-dr`)
|
|
15
|
+
- Shuffle (`-ds`)
|
|
16
|
+
- Display synchronized lyrics during playback
|
|
17
|
+
- Show song metadata tags
|
|
18
|
+
- Styled terminal music player UI
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Supported Audio Formats
|
|
23
|
+
|
|
24
|
+
- `.mp3`
|
|
25
|
+
- `.flac`
|
|
26
|
+
- `.wav`
|
|
27
|
+
- `.ogg`
|
|
28
|
+
|
|
29
|
+
### Notes
|
|
30
|
+
- Other audio formats might work, but full functionality is not guaranteed.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Lyrics Sync
|
|
35
|
+
|
|
36
|
+
RhythmSync reads embedded lyric metadata from audio files.
|
|
37
|
+
|
|
38
|
+
Supported tags include:
|
|
39
|
+
- `SYLT`, `SYLT::eng`
|
|
40
|
+
- `LYRICS`, `LYRICS:eng`
|
|
41
|
+
- `LYRICS-ENG`, `LYRICS_EN`
|
|
42
|
+
- `LYRICS_SYNCED`, `SYNCEDLYRICS`
|
|
43
|
+
|
|
44
|
+
### LRC Format
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
[00:12.34] First line
|
|
48
|
+
[00:15.67] Second line
|
|
49
|
+
[00:18.90] Third line
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- Format: `[mm:ss.xx] Line`
|
|
53
|
+
|
|
54
|
+
### Notes
|
|
55
|
+
- If no lyrics are found, a placeholder message is shown.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
### General
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
help - Lists all available commands.
|
|
65
|
+
clear - Clears the terminal.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
### Playback
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
play {path} - Plays a single audio file once.
|
|
73
|
+
play -r {path} - Plays a single audio file in repeat mode until stopped (Ctrl+C).
|
|
74
|
+
play -d {directory} - Plays all supported audio files in alphabetical order.
|
|
75
|
+
play -dr {directory} - Plays all supported audio files in alphabetical order and loops around until stopped (Ctrl+C).
|
|
76
|
+
play -ds {directory} - Plays all supported audio files in shuffled order.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
### Metadata
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
info {path} - Displays all available metadata.
|
|
84
|
+
info {path} [tags] - Displays only given metadata tags.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Notes
|
|
90
|
+
|
|
91
|
+
- Paths containing spaces must be wrapped in quotes:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
play "My Music/song.mp3"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- Use `Ctrl+C` to stop playback or exit the app
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rhythmsync"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "CLI music player with synchronized lyrics (LRC) support"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "KON/NOS R"}
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
keywords = ["music", "cli", "audio", "lrc", "player", "terminal"]
|
|
17
|
+
|
|
18
|
+
dependencies = [
|
|
19
|
+
"pygame",
|
|
20
|
+
"rich",
|
|
21
|
+
"mutagen"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
rhythmsync = "rhythmsync.main:main"
|
|
File without changes
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import os
|
|
2
|
+
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1"
|
|
3
|
+
import pygame
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
console = Console()
|
|
6
|
+
from rich.console import Group
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.align import Align
|
|
9
|
+
from rich.progress import Progress, BarColumn, TextColumn
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.layout import Layout
|
|
12
|
+
from mutagen import File
|
|
13
|
+
from re import match
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from random import shuffle
|
|
16
|
+
import shlex
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
#create rich layout
|
|
20
|
+
def make_layout():
|
|
21
|
+
layout = Layout()
|
|
22
|
+
layout.split_column(
|
|
23
|
+
Layout(name="header", size=4),
|
|
24
|
+
Layout(name="lyrics", ratio=1),
|
|
25
|
+
Layout(name="player", size=3)
|
|
26
|
+
)
|
|
27
|
+
return layout
|
|
28
|
+
|
|
29
|
+
#header section
|
|
30
|
+
def make_header(title, artist, mode = None):
|
|
31
|
+
if mode is not None:
|
|
32
|
+
mode = mode.split()
|
|
33
|
+
if mode[0] == "repeat":
|
|
34
|
+
return Panel(
|
|
35
|
+
Group(
|
|
36
|
+
Align.center(f"[bold]{title}[/bold]"),
|
|
37
|
+
Align.center(f"[#5900ab]{artist}[/#5900ab]")
|
|
38
|
+
),
|
|
39
|
+
title="⭮",
|
|
40
|
+
title_align="right",
|
|
41
|
+
style="white",
|
|
42
|
+
)
|
|
43
|
+
elif mode[0][:9] == "directory":
|
|
44
|
+
now, total = mode[1:3]
|
|
45
|
+
if mode[0] == "directory-shuffle":
|
|
46
|
+
return Panel(
|
|
47
|
+
Group(
|
|
48
|
+
Align.center(f"[bold]{title}[/bold]"),
|
|
49
|
+
Align.center(f"[#5900ab]{artist}[/#5900ab]")
|
|
50
|
+
),
|
|
51
|
+
title=f"🔀︎Playing {now} of {total}",
|
|
52
|
+
title_align="right",
|
|
53
|
+
style="white",
|
|
54
|
+
)
|
|
55
|
+
elif mode[0] == "directory-repeat":
|
|
56
|
+
return Panel(
|
|
57
|
+
Group(
|
|
58
|
+
Align.center(f"[bold]{title}[/bold]"),
|
|
59
|
+
Align.center(f"[#5900ab]{artist}[/#5900ab]")
|
|
60
|
+
),
|
|
61
|
+
title=f"⭮ Playing {now} of {total}",
|
|
62
|
+
title_align="right",
|
|
63
|
+
style="white",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return Panel(
|
|
67
|
+
Group(
|
|
68
|
+
Align.center(f"[bold]{title}[/bold]"),
|
|
69
|
+
Align.center(f"[#5900ab]{artist}[/#5900ab]")
|
|
70
|
+
),
|
|
71
|
+
title=f"Playing {now} of {total}",
|
|
72
|
+
title_align="right",
|
|
73
|
+
style="white",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return Panel(
|
|
77
|
+
Group(Align.center(f"[bold]{title}[/bold]"),
|
|
78
|
+
Align.center(f"[#5900ab]{artist}[/#5900ab]")),
|
|
79
|
+
style="white",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
#lyrics section
|
|
83
|
+
def make_lyrics(lyrics):
|
|
84
|
+
line1, line2, line3, line4, line5 = lyrics
|
|
85
|
+
return Align.center(Group(
|
|
86
|
+
Align.center(f"[#1f1f1f]{line1}[#1f1f1f]"),
|
|
87
|
+
Align.center(f"[#2f2f2f]{line2}[#2f2f2f]"),
|
|
88
|
+
Align.center(f"[bold #00d0ff]{line3}[/bold #00d0ff]"),
|
|
89
|
+
Align.center(f"[white]{line4}[white]"),
|
|
90
|
+
Align.center(f"[#afafaf]{line5}[#afafaf]")
|
|
91
|
+
),
|
|
92
|
+
vertical="middle"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
#player section
|
|
96
|
+
def make_player():
|
|
97
|
+
return Progress(
|
|
98
|
+
TextColumn("{task.description}[/]", justify="right"),
|
|
99
|
+
BarColumn(bar_width=None),
|
|
100
|
+
TextColumn("{task.fields[suffix]}", justify="right"),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
#clear the terminal
|
|
104
|
+
def clear_screen():
|
|
105
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
106
|
+
|
|
107
|
+
#format time in mm:ss.xx format
|
|
108
|
+
def format_time(milliseconds):
|
|
109
|
+
min = int((milliseconds // 1000) // 60)
|
|
110
|
+
sec = (milliseconds // 1000) % 60
|
|
111
|
+
mil = milliseconds % 1000
|
|
112
|
+
hund = int(mil // 10)
|
|
113
|
+
return f"{min:02}:{sec:02}.{hund:02}"
|
|
114
|
+
|
|
115
|
+
#format time to milliseconds
|
|
116
|
+
def unformat_time(time_str):
|
|
117
|
+
min, sec = time_str.split(":")
|
|
118
|
+
sec, hund = sec.split(".")
|
|
119
|
+
milliseconds = (
|
|
120
|
+
int(min) * 60 * 1000 + int(sec) * 1000 + int(hund) * 10)
|
|
121
|
+
return milliseconds
|
|
122
|
+
|
|
123
|
+
#get meatadata
|
|
124
|
+
def get_metadata(file_path, tags = None):
|
|
125
|
+
try:
|
|
126
|
+
audio = File(file_path)
|
|
127
|
+
|
|
128
|
+
if audio is None:
|
|
129
|
+
print(f"Error: Could not open file {file_path}")
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
lines = []
|
|
133
|
+
|
|
134
|
+
if tags is None:
|
|
135
|
+
for key, value in audio.items():
|
|
136
|
+
if isinstance(value, list):
|
|
137
|
+
value = "; ".join(str(v) for v in value)
|
|
138
|
+
|
|
139
|
+
lines.append(f"[green]{key}[/green]: {value}")
|
|
140
|
+
else:
|
|
141
|
+
for key, value in audio.items():
|
|
142
|
+
if isinstance(value, list):
|
|
143
|
+
|
|
144
|
+
if key in tags:
|
|
145
|
+
value = "; ".join(str(v) for v in value)
|
|
146
|
+
|
|
147
|
+
lines.append(f"[green]{key}[/green]: {value}")
|
|
148
|
+
|
|
149
|
+
return "\n".join(lines)
|
|
150
|
+
|
|
151
|
+
except Exception as e:
|
|
152
|
+
print(f"Error extracting metadata: {e}")
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
#get title and artist info
|
|
156
|
+
def get_ti_ar(file_path):
|
|
157
|
+
try:
|
|
158
|
+
audio = File(file_path)
|
|
159
|
+
|
|
160
|
+
if audio is None:
|
|
161
|
+
return "Unknown Title", "Unknown Artist"
|
|
162
|
+
|
|
163
|
+
title = audio.get("title", ["Unknown Title"])[0]
|
|
164
|
+
artist = audio.get("artist", ["Unknown Artist"])[0]
|
|
165
|
+
|
|
166
|
+
return title, artist
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
print(f"Error extracting metadata: {e}")
|
|
170
|
+
return "Unknown Title", "Unknown Artist"
|
|
171
|
+
|
|
172
|
+
#get lrc data from the audio file
|
|
173
|
+
def get_lrc(file_path):
|
|
174
|
+
try:
|
|
175
|
+
audio = File(file_path)
|
|
176
|
+
|
|
177
|
+
if audio is None:
|
|
178
|
+
print(f"Error: Could not open file {file_path}")
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
lrc_tag_names = ['SYLT', 'SYLT::eng', 'LYRICS', 'LYRICS:eng', 'LYRICS-ENG', 'LYRICS_EN', 'LYRICS_SYNCED', 'SYNCEDLYRICS']
|
|
182
|
+
|
|
183
|
+
for tag_name in lrc_tag_names:
|
|
184
|
+
if tag_name in audio:
|
|
185
|
+
return audio[tag_name][0]
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
except Exception as e:
|
|
189
|
+
print(f"Error extracting LRC data: {e}")
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
#get lyric lines without the tags from the lrc data
|
|
193
|
+
def format_lrc(lrc_data):
|
|
194
|
+
timestamp = r"^\[\d{2}:\d{2}\.\d{2}\]"
|
|
195
|
+
lrc_lines = lrc_data.split("\n")
|
|
196
|
+
lyrics = [[line[1:9],line[10:].strip()] for line in lrc_lines if match(timestamp, line)]
|
|
197
|
+
lyrics.insert(0,['00:00.00', ""])
|
|
198
|
+
for x in lyrics:
|
|
199
|
+
if x[1] == "":
|
|
200
|
+
x[1] = "♫"
|
|
201
|
+
return lyrics
|
|
202
|
+
|
|
203
|
+
#music player
|
|
204
|
+
def run_player(file_path, mode = None):
|
|
205
|
+
global repeat
|
|
206
|
+
try:
|
|
207
|
+
layout = make_layout()
|
|
208
|
+
|
|
209
|
+
pygame.mixer.init()
|
|
210
|
+
pygame.mixer.music.load(file_path)
|
|
211
|
+
pygame.mixer.music.play()
|
|
212
|
+
|
|
213
|
+
total_length = int(pygame.mixer.Sound(file_path).get_length()*1000)
|
|
214
|
+
|
|
215
|
+
title, artist = get_ti_ar(file_path)
|
|
216
|
+
|
|
217
|
+
lyrics_exist = False
|
|
218
|
+
raw_lrc = get_lrc(file_path)
|
|
219
|
+
if raw_lrc is not None:
|
|
220
|
+
lyrics = format_lrc(raw_lrc)
|
|
221
|
+
lyrics_exist = True
|
|
222
|
+
|
|
223
|
+
clear_screen()
|
|
224
|
+
|
|
225
|
+
layout["header"].update(make_header(title, artist, mode))
|
|
226
|
+
|
|
227
|
+
layout["lyrics"].update(Align.center("No lyrics to display", vertical="middle"))
|
|
228
|
+
|
|
229
|
+
progress = make_player()
|
|
230
|
+
layout["player"].update(progress)
|
|
231
|
+
|
|
232
|
+
with Live(layout, refresh_per_second=100):
|
|
233
|
+
playback = progress.add_task(
|
|
234
|
+
f"[red]< [#00d0ff]",
|
|
235
|
+
total=total_length,
|
|
236
|
+
suffix="[#00d0ff] [red]>")
|
|
237
|
+
|
|
238
|
+
lyric_index = 0
|
|
239
|
+
|
|
240
|
+
while pygame.mixer.music.get_busy(): #music starts
|
|
241
|
+
current_time = pygame.mixer.music.get_pos()
|
|
242
|
+
|
|
243
|
+
if lyrics_exist and lyric_index < len(lyrics):
|
|
244
|
+
if unformat_time(lyrics[lyric_index][0]) <= current_time:
|
|
245
|
+
layout["lyrics"].update(make_lyrics((
|
|
246
|
+
lyrics[lyric_index-2][1] if lyric_index > 1 else "",
|
|
247
|
+
lyrics[lyric_index-1][1] if lyric_index > 0 else "",
|
|
248
|
+
lyrics[lyric_index][1],
|
|
249
|
+
lyrics[lyric_index+1][1] if lyric_index < len(lyrics)-1 else "",
|
|
250
|
+
lyrics[lyric_index+2][1] if lyric_index < len(lyrics)-2 else ""
|
|
251
|
+
)))
|
|
252
|
+
lyric_index += 1
|
|
253
|
+
|
|
254
|
+
progress.update(playback,
|
|
255
|
+
completed=current_time,
|
|
256
|
+
description=f"[red]< [#00d0ff]{format_time(current_time)}",
|
|
257
|
+
suffix=f"[#00d0ff]{format_time(total_length - current_time)} [red]>"
|
|
258
|
+
)
|
|
259
|
+
except KeyboardInterrupt:
|
|
260
|
+
repeat = False
|
|
261
|
+
clear_screen()
|
|
262
|
+
print(f"Exitting...")
|
|
263
|
+
pygame.mixer.music.stop()
|
|
264
|
+
|
|
265
|
+
#main program
|
|
266
|
+
def main():
|
|
267
|
+
clear_screen()
|
|
268
|
+
rhythmsync_ascii ='''[#5900ab]
|
|
269
|
+
[#5900ab] _____ _ _ _ [#00d0ff]_____
|
|
270
|
+
[#5900ab]| __ \\| | | | | | [#00d0ff]/ ____|
|
|
271
|
+
[#5900ab]| |__) | |__ _ _| |_| |__ _ __ ___ [#00d0ff]| (___ _ _ _ __ ___
|
|
272
|
+
[#5900ab]| _ /| '_ \\| | | | __| '_ \\| '_ ` _ \\ [#00d0ff]\\___ \\| | | | '_ \\ / __|
|
|
273
|
+
[#5900ab]| | \\ \\| | | | |_| | |_| | | | | | | | |[#00d0ff]____) | |_| | | | | (__
|
|
274
|
+
[#5900ab]|_| \\_\\_| |_|\\__, |\\__|_| |_|_| |_| |_|[#00d0ff]_____/ \__, |_| |_|\\___|
|
|
275
|
+
[#5900ab] __/ | [#00d0ff] __/ |
|
|
276
|
+
[#5900ab] |___/ [#00d0ff] |___/
|
|
277
|
+
'''
|
|
278
|
+
console.print(Align.center(rhythmsync_ascii))
|
|
279
|
+
while True:
|
|
280
|
+
try:
|
|
281
|
+
global repeat
|
|
282
|
+
|
|
283
|
+
raw_command = input(">")
|
|
284
|
+
|
|
285
|
+
if raw_command.strip():
|
|
286
|
+
command = shlex.split(raw_command)
|
|
287
|
+
command_parts = len(command)
|
|
288
|
+
|
|
289
|
+
#help command
|
|
290
|
+
if command[0] == "help" and command_parts == 1:
|
|
291
|
+
console.print('''command list:
|
|
292
|
+
[green]help[/green] - Lists all available commands.
|
|
293
|
+
[green]clear[/green] - Clears the terminal.
|
|
294
|
+
[green]play[/green] [cyan]{par} {path}[/cyan] - Plays the audio file located at the specified [cyan]{path}[/cyan].
|
|
295
|
+
If [cyan]{par}[/cyan] is "-r", the audio file plays in repeat until stopped.
|
|
296
|
+
If [cyan]{par}[/cyan] is "-d" and [cyan]{path}[/cyan] is a directory, the audio files of given directory play in alphabetical order.
|
|
297
|
+
If [cyan]{par}[/cyan] is "-dr" and [cyan]{path}[/cyan] is a directory, the audio files of given directory play in alphabetical order and loop around until stopped.
|
|
298
|
+
If [cyan]{par}[/cyan] is "-ds" and [cyan]{path}[/cyan] is a directory, the audio files of given directory play in shuffled order.
|
|
299
|
+
If [cyan]{par}[/cyan] is omitted, the audio file plays once.
|
|
300
|
+
[green]info[/green] [cyan]{path} {tags}[/cyan] - Displays metadata for the file at [cyan]{path}[/cyan].
|
|
301
|
+
If [cyan]{tags}[/cyan] is provided (separate them with space for multiple tags), shows only those specific tags and their respective values.
|
|
302
|
+
If [cyan]{tags}[/cyan] is omitted, shows all available tags and their respective values.
|
|
303
|
+
'''
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
#clear command
|
|
307
|
+
elif command[0] == "clear" and command_parts == 1:
|
|
308
|
+
clear_screen()
|
|
309
|
+
console.print(Align.center(rhythmsync_ascii))
|
|
310
|
+
|
|
311
|
+
#play command
|
|
312
|
+
elif command[0] == "play":
|
|
313
|
+
if command_parts == 2:
|
|
314
|
+
file_path = str(Path(command[1]).expanduser().resolve())
|
|
315
|
+
if os.path.exists(file_path):
|
|
316
|
+
run_player(file_path)
|
|
317
|
+
clear_screen()
|
|
318
|
+
console.print(Align.center(rhythmsync_ascii))
|
|
319
|
+
else:
|
|
320
|
+
print("Please enter a valid file path.")
|
|
321
|
+
elif command_parts == 3:
|
|
322
|
+
par = command[1]
|
|
323
|
+
file_path = str(Path(command[2]).expanduser().resolve())
|
|
324
|
+
if os.path.exists(file_path):
|
|
325
|
+
if par == "-r":
|
|
326
|
+
repeat = True
|
|
327
|
+
while repeat:
|
|
328
|
+
run_player(file_path, "repeat")
|
|
329
|
+
clear_screen()
|
|
330
|
+
console.print(Align.center(rhythmsync_ascii))
|
|
331
|
+
elif par == "-d":
|
|
332
|
+
extensions = (".mp3", ".flac", ".wav", ".ogg")
|
|
333
|
+
audio_files = [
|
|
334
|
+
f for f in Path(file_path).iterdir()
|
|
335
|
+
if f.suffix.lower() in extensions
|
|
336
|
+
]
|
|
337
|
+
audio_files.sort()
|
|
338
|
+
repeat = True
|
|
339
|
+
i = 0
|
|
340
|
+
for file in audio_files:
|
|
341
|
+
i += 1
|
|
342
|
+
run_player(file, f"directory {i} {len(audio_files)}")
|
|
343
|
+
if repeat == False:
|
|
344
|
+
break
|
|
345
|
+
clear_screen()
|
|
346
|
+
console.print(Align.center(rhythmsync_ascii))
|
|
347
|
+
elif par == "-ds":
|
|
348
|
+
extensions = (".mp3", ".flac", ".wav", ".ogg")
|
|
349
|
+
audio_files = [
|
|
350
|
+
f for f in Path(file_path).iterdir()
|
|
351
|
+
if f.suffix.lower() in extensions
|
|
352
|
+
]
|
|
353
|
+
shuffle(audio_files)
|
|
354
|
+
repeat = True
|
|
355
|
+
i = 0
|
|
356
|
+
for file in audio_files:
|
|
357
|
+
i += 1
|
|
358
|
+
run_player(file, f"directory-shuffle {i} {len(audio_files)}")
|
|
359
|
+
if repeat == False:
|
|
360
|
+
break
|
|
361
|
+
clear_screen()
|
|
362
|
+
console.print(Align.center(rhythmsync_ascii))
|
|
363
|
+
elif par == "-dr":
|
|
364
|
+
extensions = (".mp3", ".flac", ".wav", ".ogg")
|
|
365
|
+
audio_files = [
|
|
366
|
+
f for f in Path(file_path).iterdir()
|
|
367
|
+
if f.suffix.lower() in extensions
|
|
368
|
+
]
|
|
369
|
+
audio_files.sort()
|
|
370
|
+
repeat = True
|
|
371
|
+
while repeat:
|
|
372
|
+
i = 0
|
|
373
|
+
for file in audio_files:
|
|
374
|
+
i += 1
|
|
375
|
+
run_player(file, f"directory-repeat {i} {len(audio_files)}")
|
|
376
|
+
if repeat == False:
|
|
377
|
+
break
|
|
378
|
+
clear_screen()
|
|
379
|
+
console.print(Align.center(rhythmsync_ascii))
|
|
380
|
+
else:
|
|
381
|
+
print("Please enter a valid file path.")
|
|
382
|
+
else:
|
|
383
|
+
print("Please enter a valid file path and parameters.")
|
|
384
|
+
|
|
385
|
+
#info command
|
|
386
|
+
elif command[0] == "info":
|
|
387
|
+
if command_parts >= 2:
|
|
388
|
+
file_path = Path(command[1]).expanduser().resolve()
|
|
389
|
+
par = tuple(command[2:]) or None
|
|
390
|
+
if os.path.exists(file_path):
|
|
391
|
+
console.print(get_metadata(file_path, par), highlight=False)
|
|
392
|
+
else:
|
|
393
|
+
print("Please enter a valid file path.")
|
|
394
|
+
else:
|
|
395
|
+
print("Please enter a valid file path and parameters.")
|
|
396
|
+
|
|
397
|
+
#invalid command
|
|
398
|
+
else:
|
|
399
|
+
print("Invalid command! Enter 'help' to display command list.")
|
|
400
|
+
|
|
401
|
+
except KeyboardInterrupt:
|
|
402
|
+
print(f"Exitting...")
|
|
403
|
+
break
|
|
404
|
+
except Exception as e:
|
|
405
|
+
clear_screen()
|
|
406
|
+
console.print(Align.center(rhythmsync_ascii))
|
|
407
|
+
print(f"Error: {e}")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == "__main__":
|
|
411
|
+
main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rhythmsync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI music player with synchronized lyrics (LRC) support
|
|
5
|
+
Author: KON/NOS R
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: music,cli,audio,lrc,player,terminal
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: pygame
|
|
11
|
+
Requires-Dist: rich
|
|
12
|
+
Requires-Dist: mutagen
|
|
13
|
+
|
|
14
|
+
# RhythmSync
|
|
15
|
+
|
|
16
|
+
CLI audio player that plays music while displaying synchronized lyrics (LRC).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- Play single audio files:
|
|
23
|
+
- Once
|
|
24
|
+
- In loop (`-r`)
|
|
25
|
+
- Play all supported audio files in a directory:
|
|
26
|
+
- Alphabetical order (`-d`)
|
|
27
|
+
- Alphabetical loop (`-dr`)
|
|
28
|
+
- Shuffle (`-ds`)
|
|
29
|
+
- Display synchronized lyrics during playback
|
|
30
|
+
- Show song metadata tags
|
|
31
|
+
- Styled terminal music player UI
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Supported Audio Formats
|
|
36
|
+
|
|
37
|
+
- `.mp3`
|
|
38
|
+
- `.flac`
|
|
39
|
+
- `.wav`
|
|
40
|
+
- `.ogg`
|
|
41
|
+
|
|
42
|
+
### Notes
|
|
43
|
+
- Other audio formats might work, but full functionality is not guaranteed.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Lyrics Sync
|
|
48
|
+
|
|
49
|
+
RhythmSync reads embedded lyric metadata from audio files.
|
|
50
|
+
|
|
51
|
+
Supported tags include:
|
|
52
|
+
- `SYLT`, `SYLT::eng`
|
|
53
|
+
- `LYRICS`, `LYRICS:eng`
|
|
54
|
+
- `LYRICS-ENG`, `LYRICS_EN`
|
|
55
|
+
- `LYRICS_SYNCED`, `SYNCEDLYRICS`
|
|
56
|
+
|
|
57
|
+
### LRC Format
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
[00:12.34] First line
|
|
61
|
+
[00:15.67] Second line
|
|
62
|
+
[00:18.90] Third line
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- Format: `[mm:ss.xx] Line`
|
|
66
|
+
|
|
67
|
+
### Notes
|
|
68
|
+
- If no lyrics are found, a placeholder message is shown.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Commands
|
|
73
|
+
|
|
74
|
+
### General
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
help - Lists all available commands.
|
|
78
|
+
clear - Clears the terminal.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
### Playback
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
play {path} - Plays a single audio file once.
|
|
86
|
+
play -r {path} - Plays a single audio file in repeat mode until stopped (Ctrl+C).
|
|
87
|
+
play -d {directory} - Plays all supported audio files in alphabetical order.
|
|
88
|
+
play -dr {directory} - Plays all supported audio files in alphabetical order and loops around until stopped (Ctrl+C).
|
|
89
|
+
play -ds {directory} - Plays all supported audio files in shuffled order.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
### Metadata
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
info {path} - Displays all available metadata.
|
|
97
|
+
info {path} [tags] - Displays only given metadata tags.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Notes
|
|
103
|
+
|
|
104
|
+
- Paths containing spaces must be wrapped in quotes:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
play "My Music/song.mp3"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- Use `Ctrl+C` to stop playback or exit the app
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
rhythmsync/__init__.py
|
|
4
|
+
rhythmsync/main.py
|
|
5
|
+
rhythmsync.egg-info/PKG-INFO
|
|
6
|
+
rhythmsync.egg-info/SOURCES.txt
|
|
7
|
+
rhythmsync.egg-info/dependency_links.txt
|
|
8
|
+
rhythmsync.egg-info/entry_points.txt
|
|
9
|
+
rhythmsync.egg-info/requires.txt
|
|
10
|
+
rhythmsync.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rhythmsync
|