└── lyrics.py /lyrics.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os, sys, re, shutil, difflib, tempfile, requests 3 | import numpy as np 4 | import sounddevice as sd 5 | import soundfile as sf 6 | import pyfiglet 7 | 8 | #if the lyrics don't appear, make sure the file is the song name, preferably (songname, author).mp3 9 | AUDIO_FILE = "Sweather Weather.mp3" # your song hear 10 | START_TIME = 39 # if you want to skip in certain parts 11 | 12 | LRC_FOLDER = os.path.expanduser("~/lyrics") 13 | BLOCKSIZE = 2048 14 | 15 | 16 | 17 | # if the lyrics don't sync you can manually adjust its' offset here 18 | # in seconds.,, positive number for backwards, negative for forwards. 19 | LYRIC_OFFSET = -1.7 20 | 21 | 22 | def fetch_lrc_from_lrclib(title: str, artist: str = "") -> str | None: 23 | try: 24 | r = requests.get( 25 | "https://lrclib.net/api/search", 26 | params={"q": f"{title} {artist}".strip()}, 27 | timeout=10, 28 | ) 29 | r.raise_for_status() 30 | for item in r.json(): 31 | if item.get("syncedLyrics"): 32 | return item["syncedLyrics"] 33 | except Exception as e: 34 | print("LRCLIB fetch failed:", e) 35 | return None 36 | 37 | def find_closest_lrc(song_title: str, folder: str) -> str | None: 38 | if not os.path.isdir(folder): 39 | return None 40 | bases = [os.path.splitext(f)[0] for f in os.listdir(folder) 41 | if f.lower().endswith(".lrc")] 42 | closest = difflib.get_close_matches(song_title, bases, n=1, cutoff=0.4) 43 | return os.path.join(folder, closest[0] + ".lrc") if closest else None 44 | 45 | def parse_lrc(path: str): 46 | pattern = re.compile(r"\[(\d+):(\d+(?:\.\d+)?)\](.*)") 47 | out = [] 48 | with open(path, "r", encoding="utf-8") as f: 49 | for line in f: 50 | m = pattern.match(line.strip()) 51 | if m: 52 | mins, secs, txt = m.groups() 53 | t = int(mins)*60 + float(secs) + LYRIC_OFFSET 54 | out.append((max(0.0, t), txt.strip())) 55 | return sorted(out) 56 | 57 | base = os.path.splitext(os.path.basename(AUDIO_FILE))[0] 58 | artist_guess, title_guess = ("", base) 59 | if "-" in base: 60 | artist_guess, title_guess = [x.strip() for x in base.split("-", 1)] 61 | 62 | lyrics_path = None 63 | lrc_text = fetch_lrc_from_lrclib(title_guess, artist_guess) 64 | if lrc_text: 65 | tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".lrc") 66 | tmp.write(lrc_text.encode("utf-8")); tmp.close() 67 | lyrics_path = tmp.name 68 | print("Fetched synced lyrics from LRCLIB.") 69 | else: 70 | match = find_closest_lrc(title_guess, LRC_FOLDER) 71 | if match: 72 | lyrics_path = match 73 | print(f"Using closest local match: {match}") 74 | else: 75 | print("No lyrics found online or locally.") 76 | 77 | lyrics = parse_lrc(lyrics_path) if lyrics_path else [] 78 | 79 | data, samplerate = sf.read(AUDIO_FILE, dtype="float32") 80 | if data.ndim > 1: 81 | data = np.mean(data, axis=1) 82 | data = data / np.max(np.abs(data)) 83 | start_idx = int(START_TIME * samplerate) 84 | 85 | term_columns, _ = shutil.get_terminal_size((200, 80)) 86 | columns = min(80, term_columns) 87 | center = 6 88 | lyric_index = 0 89 | for i, (t, _) in enumerate(lyrics): 90 | if t >= START_TIME: 91 | lyric_index = max(0, i - 1) 92 | break 93 | 94 | def smooth(vals, window=5): 95 | if len(vals) < window: return vals 96 | k = np.ones(window)/window 97 | return np.convolve(vals, k, mode="same") 98 | 99 | def hsv_to_rgb(h,s,v): 100 | i = int(h*6); f = h*6 - i 101 | p = int(255*v*(1-s)); q = int(255*v*(1-f*s)); t = int(255*v*(1-(1-f)*s)) 102 | v = int(255*v); i = i % 6 103 | return [(v,t,p),(q,v,p),(p,v,t),(p,q,v),(t,p,v),(v,p,q)][i] 104 | 105 | def colorize(text, r,g,b): 106 | return f"\033[38;2;{r};{g};{b}m{text}\033[0m" 107 | 108 | def dim_color(text, r, g, b, factor=0.4): 109 | rr = int(r*factor); gg = int(g*factor); bb = int(b*factor) 110 | return f"\033[38;2;{rr};{gg};{bb}m{text}\033[0m" 111 | 112 | def render_lyrics_block(idx, r, g, b): 113 | lines = [] 114 | if idx < len(lyrics): 115 | lines.append(colorize(lyrics[idx][1], r, g, b)) 116 | for n in range(1, 3): 117 | if idx + n < len(lyrics): 118 | lines.append("") 119 | lines.append(dim_color(lyrics[idx + n][1], r, g, b, 0.4)) 120 | return "\n".join(lines) 121 | 122 | def callback(outdata, frames, time_info, status): 123 | global start_idx, lyric_index 124 | if status: print(status, file=sys.stderr) 125 | 126 | chunk = data[start_idx:start_idx+frames] 127 | if len(chunk) < frames: 128 | outdata[:len(chunk),0] = chunk 129 | outdata[len(chunk):] = 0 130 | raise sd.CallbackStop 131 | else: 132 | outdata[:,0] = chunk 133 | 134 | play_time = start_idx / samplerate 135 | if lyrics: 136 | while lyric_index + 1 < len(lyrics) and play_time >= lyrics[lyric_index + 1][0]: 137 | lyric_index += 1 138 | 139 | step = max(1, len(chunk)//columns) 140 | levels = np.abs(chunk[::step]) 141 | levels = smooth(levels, window=6) 142 | levels = np.interp(np.clip(levels,0,1), [0,1], [0,center-1]).astype(int) 143 | 144 | hue = [0.15,0.3,0.6,0.8][int((play_time*0.2)%4)] 145 | r,g,b = hsv_to_rgb(hue,0.5,0.9) 146 | 147 | screen=[] 148 | for row in range(center*2): 149 | line=[] 150 | for lvl in levels: 151 | if row==center: 152 | line.append(colorize("─",r,g,b)) 153 | elif row
center and (row-center)<=lvl: 156 | line.append(colorize("█",r,g,b)) 157 | else: 158 | line.append(" ") 159 | screen.append("".join(line)) 160 | 161 | if lyrics: 162 | screen.append("") 163 | screen.append(render_lyrics_block(lyric_index, r, g, b)) 164 | 165 | sys.stdout.write("\033[H\033[J" + "\n".join(screen)) 166 | sys.stdout.flush() 167 | start_idx += frames 168 | 169 | with sd.OutputStream(channels=1, samplerate=samplerate, 170 | callback=callback, blocksize=BLOCKSIZE, latency="low"): 171 | sd.sleep(int((len(data) - start_idx) / samplerate * 1000)) 172 | --------------------------------------------------------------------------------