1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
|
#!/usr/bin/env python3
import socket, time, subprocess, json, sys, select, argparse, os, signal, random, threading
import requests
from datetime import datetime
def signal_handler(sig, frame):
try:
addr, tags = b"/chatbox/input\0\0", b",sT\0"
socket.socket(socket.AF_INET, socket.SOCK_DGRAM).sendto(addr + tags + b" \0", ("127.0.0.1", 9000))
except: pass
os._exit(0)
signal.signal(signal.SIGINT, signal_handler)
parser = argparse.ArgumentParser(description="vrc-py: NVIDIA VRChat OSC HUD")
parser.add_argument('--message', type=str, default=".")
parser.add_argument('--order', nargs='+', default=['line1', 'line2', 'line3', 'line4', 'line5'])
parser.add_argument('--player', type=str, default="spotify")
parser.add_argument('--debug', action='store_true', help="Print OSC output to terminal")
args = parser.parse_args()
lyric_data = {"lyrics": [], "loading": False, "song_id": "", "last_idx": -1}
def debug_log(msg):
if args.debug:
print(f"[{datetime.now().strftime('%H:%M:%S')}] DEBUG: {msg}")
def fetch_lyrics_bg(artist, title, song_id):
global lyric_data
lyric_data["loading"] = True
clean_t = title.split(" - ")[0].split(" (")[0].split(" [")[0].split(" feat.")[0].strip()
clean_a = artist.split(",")[0].split(" & ")[0].strip()
try:
r = requests.get("https://lrclib.net/api/get",
params={'artist_name': clean_a, 'track_name': clean_t},
headers={'User-Agent': 'vrc-py-hud/1.0'}, timeout=10)
if r.status_code == 200:
res = r.json()
raw = res.get('syncedLyrics') or res.get('plainLyrics')
if raw:
parsed = []
for l in raw.split('\n'):
if "[" in l and "]" in l:
try:
ts = int(l[1:3]) * 60 + float(l[4:9])
txt = l[10:].strip()
parsed.append({"ts": ts, "txt": txt})
except: continue
elif l.strip():
parsed.append({"ts": 0, "txt": l.strip()})
lyric_data["lyrics"] = parsed
debug_log(f"Fetched {len(parsed)} lyric lines")
except Exception as e:
debug_log(f"Lyric Fetch Error: {e}")
lyric_data["loading"] = False
lyric_data["song_id"] = song_id
lyric_data["last_idx"] = -1
def get_audio_data():
try:
p = f"--player={args.player}"
meta = lambda k: subprocess.check_output(["playerctl", p, "metadata", k], text=True).strip()
pos = float(subprocess.check_output(["playerctl", p, "position"], text=True).strip())
stat = subprocess.check_output(["playerctl", p, "status"], text=True).strip()
vol = float(subprocess.check_output(["playerctl", p, "volume"], text=True).strip())
try: length = float(meta("mpris:length")) / 1000000
except: length = 0
return meta("artist"), meta("title"), pos, length, stat, vol
except: return None, None, 0, 0, "Closed", 0
def get_stats():
try:
gpu = subprocess.check_output(["nvidia-smi", "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits"], text=True).strip()
with open('/proc/stat') as f:
l = f.readline().split()
cpu = round((1 - float(l[4])/sum(float(i) for i in l[1:])) * 100)
with open('/proc/meminfo') as f:
m = {ln.split()[0]: int(ln.split()[1]) for ln in f.readlines()[:4]}
ram = round(((m['MemTotal:'] - m['MemAvailable:']) / m['MemTotal:']) * 100)
return f"C:{cpu}% | G:{gpu}% | R:{ram}%"
except: return "Stats Err"
def fmt_time(s):
m, s = divmod(int(s), 60); h, m = divmod(m, 60)
return f"{h:02}:{m:02}:{s:02}" if h > 0 else f"{m:02}:{s:02}"
def send(text):
debug_log(f"Sending OSC:\n{text}")
addr, tags = b"/chatbox/input\0\0", b",sT\0"
b_text = text.encode('utf-8') + b"\0"
b_text += b"\0" * (4 - (len(b_text) % 4)) if len(b_text) % 4 != 0 else b""
socket.socket(socket.AF_INET, socket.SOCK_DGRAM).sendto(addr + tags + b_text, ("127.0.0.1", 9000))
cur_song, msg_ovr, last, last_pos, last_send_time = "", args.message, "", 0, 0
active_lyric, lyric_locked_until = "πΆπΆπΆ", 0
while True:
if select.select([sys.stdin], [], [], 0)[0]:
msg_ovr = sys.stdin.readline().strip() or msg_ovr
try:
art, tit, pos, length, stat, vol = get_audio_data()
now_ts = time.time()
if tit and tit != cur_song:
cur_song = tit
lyric_data["lyrics"], lyric_data["last_idx"] = [], -1
threading.Thread(target=fetch_lyrics_bg, args=(art, tit, tit), daemon=True).start()
if pos < last_pos - 2 or pos > last_pos + 5:
lyric_data["last_idx"] = -1
if now_ts >= lyric_locked_until:
if lyric_data["lyrics"]:
best_idx = -1
for i, l in enumerate(lyric_data["lyrics"]):
if l["ts"] <= pos: best_idx = i
if best_idx != -1 and best_idx != lyric_data["last_idx"]:
active_lyric = f"π€ {lyric_data['lyrics'][best_idx]['txt']}" if lyric_data['lyrics'][best_idx]['txt'] else "πΆπΆπΆ"
lyric_data["last_idx"], lyric_locked_until = best_idx, now_ts + 1.2
vol_pct = int(vol * 100)
p_flag = " βΈ" if (pos == last_pos and stat != "Playing") else ""
bar_len = 10
fill = int((pos/length) * bar_len) if length > 0 else 0
# Line 5: Volume leading, no clock
l5 = f"π{vol_pct}% | {fmt_time(pos)}/{fmt_time(length)} [{'β°'*fill}{'β±'*(bar_len-fill)}]{p_flag}"
last_pos = pos
data = {
"line1": f"π΅ {tit} - {art}" if tit else "Idle",
"line2": active_lyric,
"line3": get_stats(),
"line4": f"π¬ {msg_ovr}",
"line5": l5
}
out = "\n".join([data[k] for k in args.order if k in data])
if out != last and (now_ts - last_send_time) > 1.1:
send(out)
last, last_send_time = out, now_ts
except Exception as e:
debug_log(f"HUD Error: {e}")
time.sleep(0.3)
|