orbwave 0.1.0__py3-none-any.whl
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.
Potentially problematic release.
This version of orbwave might be problematic. Click here for more details.
- orbwave/__init__.py +347 -0
- orbwave/utils/__init__.py +0 -0
- orbwave/utils/album_cover.py +112 -0
- orbwave-0.1.0.dist-info/METADATA +86 -0
- orbwave-0.1.0.dist-info/RECORD +8 -0
- orbwave-0.1.0.dist-info/WHEEL +4 -0
- orbwave-0.1.0.dist-info/entry_points.txt +3 -0
- orbwave-0.1.0.dist-info/licenses/LICENSE +19 -0
orbwave/__init__.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
if len(sys.argv) < 2:
|
|
4
|
+
sys.argv.append("audio-visualizer")
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import soundcard as sc
|
|
8
|
+
import pygame
|
|
9
|
+
import colorsys
|
|
10
|
+
import math
|
|
11
|
+
import random
|
|
12
|
+
import threading
|
|
13
|
+
import queue
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import asyncio
|
|
17
|
+
from orbwave.utils.album_cover import media_art_loop
|
|
18
|
+
|
|
19
|
+
WIDTH, HEIGHT = 1920, 1200
|
|
20
|
+
FPS = 144
|
|
21
|
+
CHUNK = 1024
|
|
22
|
+
initial_BARS = 128
|
|
23
|
+
RADIUS = 200
|
|
24
|
+
SHAKE_INTENSITY = 5
|
|
25
|
+
BASS_BINS = 6
|
|
26
|
+
PARTICLE_COUNT = 150
|
|
27
|
+
ART_FILE = "current_media_art.png"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_loopback_mic():
|
|
31
|
+
|
|
32
|
+
all_mics = sc.all_microphones(include_loopback=True)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
speaker = sc.default_speaker()
|
|
36
|
+
speaker_name = speaker.name
|
|
37
|
+
print(f"[audio] Default speaker: {speaker_name}")
|
|
38
|
+
for m in all_mics:
|
|
39
|
+
if "monitor" in m.name.lower() and speaker_name.lower() in m.name.lower():
|
|
40
|
+
print(f"[audio] ✓ Using monitor of default speaker: {m.name}")
|
|
41
|
+
return m
|
|
42
|
+
except Exception as e:
|
|
43
|
+
print(f"[audio] Could not get default speaker: {e}")
|
|
44
|
+
|
|
45
|
+
for m in all_mics:
|
|
46
|
+
if "monitor" in m.name.lower() or "loopback" in m.name.lower():
|
|
47
|
+
print(f"[audio] Using monitor source: {m.name}")
|
|
48
|
+
return m
|
|
49
|
+
|
|
50
|
+
mic = sc.default_microphone()
|
|
51
|
+
print(f"[audio] Falling back to default microphone: {mic.name}")
|
|
52
|
+
return mic
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
mic = _get_loopback_mic()
|
|
56
|
+
|
|
57
|
+
audio_queue: queue.Queue = queue.Queue(maxsize=4)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def audio_thread_function():
|
|
61
|
+
"""Record audio in a background thread, pushing frames into audio_queue."""
|
|
62
|
+
try:
|
|
63
|
+
with mic.recorder(samplerate=44100, blocksize=CHUNK) as rec:
|
|
64
|
+
while True:
|
|
65
|
+
data = rec.record(numframes=CHUNK)
|
|
66
|
+
if audio_queue.full():
|
|
67
|
+
try:
|
|
68
|
+
audio_queue.get_nowait()
|
|
69
|
+
except queue.Empty:
|
|
70
|
+
pass
|
|
71
|
+
audio_queue.put_nowait(data)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
print(f"[audio] Recording thread crashed: {e}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def brighten_color(color, min_brightness=100):
|
|
77
|
+
r, g, b = color
|
|
78
|
+
brightness = (r + g + b) / 3
|
|
79
|
+
if brightness < min_brightness:
|
|
80
|
+
factor = min_brightness / max(1, brightness)
|
|
81
|
+
r = min(255, int(r * factor))
|
|
82
|
+
g = min(255, int(g * factor))
|
|
83
|
+
b = min(255, int(b * factor))
|
|
84
|
+
return (r, g, b)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def start_asyncio_thread():
|
|
88
|
+
asyncio.run(media_art_loop())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _init_pygame_wayland():
|
|
92
|
+
if "WAYLAND_DISPLAY" in os.environ:
|
|
93
|
+
os.environ.setdefault("SDL_VIDEODRIVER", "wayland")
|
|
94
|
+
|
|
95
|
+
pygame.init()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
screen = pygame.display.set_mode((WIDTH, HEIGHT), pygame.FULLSCREEN)
|
|
99
|
+
except pygame.error:
|
|
100
|
+
print("[display] Fullscreen failed, trying borderless window")
|
|
101
|
+
screen = pygame.display.set_mode((WIDTH, HEIGHT), pygame.NOFRAME)
|
|
102
|
+
|
|
103
|
+
pygame.display.set_caption("Audio Visualizer")
|
|
104
|
+
return screen
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_visualizer():
|
|
108
|
+
|
|
109
|
+
media_art_thread = threading.Thread(target=start_asyncio_thread, daemon=True)
|
|
110
|
+
media_art_thread.start()
|
|
111
|
+
print("[visual] Media art loop started in a separate thread.")
|
|
112
|
+
|
|
113
|
+
screen = _init_pygame_wayland()
|
|
114
|
+
clock = pygame.time.Clock()
|
|
115
|
+
pygame.mouse.set_visible(False)
|
|
116
|
+
|
|
117
|
+
BARS = initial_BARS
|
|
118
|
+
hue = 0
|
|
119
|
+
spectrum_smooth = np.zeros(BARS)
|
|
120
|
+
center_x, center_y = WIDTH // 2, HEIGHT // 2
|
|
121
|
+
rotation_angle = 0
|
|
122
|
+
particles = []
|
|
123
|
+
current_art = None
|
|
124
|
+
last_file_mod_time = 0
|
|
125
|
+
dynamic_radius = 0
|
|
126
|
+
current_bg_brightness = 10
|
|
127
|
+
avg_color = (10, 10, 20)
|
|
128
|
+
palette = [avg_color]
|
|
129
|
+
loaded_image = None
|
|
130
|
+
|
|
131
|
+
for _ in range(PARTICLE_COUNT):
|
|
132
|
+
particles.append(
|
|
133
|
+
{
|
|
134
|
+
"x": random.uniform(0, WIDTH),
|
|
135
|
+
"y": random.uniform(0, HEIGHT),
|
|
136
|
+
"size": random.uniform(1, 4),
|
|
137
|
+
"speed": random.uniform(0.2, 1),
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def get_spectrum(audio_data):
|
|
142
|
+
nonlocal spectrum_smooth
|
|
143
|
+
mono = audio_data[:, 0] * np.hanning(len(audio_data))
|
|
144
|
+
fft = np.abs(np.fft.rfft(mono))[:BARS]
|
|
145
|
+
fft = np.log1p(fft)
|
|
146
|
+
spectrum_smooth = 0.7 * spectrum_smooth + 0.3 * fft
|
|
147
|
+
return spectrum_smooth
|
|
148
|
+
|
|
149
|
+
audio_thread = threading.Thread(target=audio_thread_function, daemon=True)
|
|
150
|
+
audio_thread.start()
|
|
151
|
+
|
|
152
|
+
running = True
|
|
153
|
+
while running:
|
|
154
|
+
for event in pygame.event.get():
|
|
155
|
+
if event.type == pygame.QUIT or (
|
|
156
|
+
event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE
|
|
157
|
+
):
|
|
158
|
+
running = False
|
|
159
|
+
elif event.type == pygame.KEYDOWN:
|
|
160
|
+
if event.key == pygame.K_UP:
|
|
161
|
+
new_bars = min(BARS * 2, 512)
|
|
162
|
+
if new_bars != BARS:
|
|
163
|
+
BARS = new_bars
|
|
164
|
+
spectrum_smooth = np.zeros(BARS)
|
|
165
|
+
print(f"BARS increased to: {BARS}")
|
|
166
|
+
elif event.key == pygame.K_DOWN:
|
|
167
|
+
new_bars = max(BARS // 2, 32)
|
|
168
|
+
if new_bars != BARS:
|
|
169
|
+
BARS = new_bars
|
|
170
|
+
spectrum_smooth = np.zeros(BARS)
|
|
171
|
+
print(f"BARS decreased to: {BARS}")
|
|
172
|
+
|
|
173
|
+
if os.path.exists(ART_FILE):
|
|
174
|
+
mod_time = os.path.getmtime(ART_FILE)
|
|
175
|
+
if mod_time > last_file_mod_time:
|
|
176
|
+
try:
|
|
177
|
+
loaded_image = pygame.image.load(ART_FILE).convert_alpha()
|
|
178
|
+
size = int(RADIUS * 1.8)
|
|
179
|
+
current_art = pygame.transform.scale(loaded_image, (size, size))
|
|
180
|
+
last_file_mod_time = mod_time
|
|
181
|
+
|
|
182
|
+
arr = pygame.surfarray.array3d(loaded_image) # (w, h, 3)
|
|
183
|
+
avg = arr.mean(axis=(0, 1))
|
|
184
|
+
avg_color = tuple(int(c) for c in avg)
|
|
185
|
+
|
|
186
|
+
pixels = arr.reshape(-1, 3)
|
|
187
|
+
max_samples = 8000
|
|
188
|
+
if pixels.shape[0] > max_samples:
|
|
189
|
+
step = pixels.shape[0] // max_samples
|
|
190
|
+
pixels = pixels[::step]
|
|
191
|
+
|
|
192
|
+
q = (pixels >> 4).astype(np.int32)
|
|
193
|
+
keys = (q[:, 0] << 8) + (q[:, 1] << 4) + q[:, 2]
|
|
194
|
+
unique, counts = np.unique(keys, return_counts=True)
|
|
195
|
+
|
|
196
|
+
k = 6
|
|
197
|
+
palette = []
|
|
198
|
+
if unique.size:
|
|
199
|
+
top_idx = np.argsort(counts)[-k:]
|
|
200
|
+
top_keys = unique[top_idx][::-1]
|
|
201
|
+
for key in top_keys:
|
|
202
|
+
r = (((key >> 8) & 0xF) << 4) + 8
|
|
203
|
+
g = (((key >> 4) & 0xF) << 4) + 8
|
|
204
|
+
b = ((key & 0xF) << 4) + 8
|
|
205
|
+
if (r + g + b) < 40:
|
|
206
|
+
continue
|
|
207
|
+
if (r + g + b) > 740:
|
|
208
|
+
continue
|
|
209
|
+
palette.append((int(r), int(g), int(b)))
|
|
210
|
+
|
|
211
|
+
if not palette:
|
|
212
|
+
palette = [avg_color]
|
|
213
|
+
|
|
214
|
+
except Exception as e:
|
|
215
|
+
print(f"[visual] Error loading image / palette extraction: {e}")
|
|
216
|
+
current_art = None
|
|
217
|
+
avg_color = (10, 10, 20)
|
|
218
|
+
palette = [avg_color]
|
|
219
|
+
else:
|
|
220
|
+
current_art = None
|
|
221
|
+
loaded_image = None
|
|
222
|
+
|
|
223
|
+
audio_data = None
|
|
224
|
+
while not audio_queue.empty():
|
|
225
|
+
try:
|
|
226
|
+
audio_data = audio_queue.get_nowait()
|
|
227
|
+
except queue.Empty:
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
if audio_data is not None:
|
|
231
|
+
spectrum = get_spectrum(audio_data)
|
|
232
|
+
else:
|
|
233
|
+
spectrum = spectrum_smooth
|
|
234
|
+
|
|
235
|
+
volume = np.mean(spectrum[:BASS_BINS]) * 0.5
|
|
236
|
+
dynamic_radius = RADIUS + volume * 25
|
|
237
|
+
shake_x = shake_y = 0
|
|
238
|
+
|
|
239
|
+
target_bg_brightness = 1.0
|
|
240
|
+
if volume > 1.1:
|
|
241
|
+
shake_x = random.randint(-SHAKE_INTENSITY, SHAKE_INTENSITY)
|
|
242
|
+
shake_y = random.randint(-SHAKE_INTENSITY, SHAKE_INTENSITY)
|
|
243
|
+
target_bg_brightness = 1.0 + volume * 0.5
|
|
244
|
+
|
|
245
|
+
current_bg_brightness = current_bg_brightness * 0.9 + target_bg_brightness * 0.1
|
|
246
|
+
|
|
247
|
+
darken_factor = 0.35
|
|
248
|
+
base_r = int(avg_color[0] * darken_factor)
|
|
249
|
+
base_g = int(avg_color[1] * darken_factor)
|
|
250
|
+
base_b = int(avg_color[2] * darken_factor)
|
|
251
|
+
# base_r = int(50 * darken_factor)
|
|
252
|
+
# base_g = int(50 * darken_factor)
|
|
253
|
+
# base_b = int(50 * darken_factor)
|
|
254
|
+
|
|
255
|
+
r = min(255, int(base_r * current_bg_brightness))
|
|
256
|
+
g = min(255, int(base_g * current_bg_brightness))
|
|
257
|
+
b = min(255, int(base_b * current_bg_brightness))
|
|
258
|
+
screen.fill((r, g, b))
|
|
259
|
+
|
|
260
|
+
hue = (hue + 0.002) % 1.0
|
|
261
|
+
color = tuple(int(c * 255) for c in colorsys.hsv_to_rgb(hue, 1, 1))
|
|
262
|
+
|
|
263
|
+
# === Particles ===
|
|
264
|
+
for p in particles:
|
|
265
|
+
p["y"] += p["speed"] * (1 + volume * 2)
|
|
266
|
+
if p["y"] > HEIGHT:
|
|
267
|
+
p["y"] = 0
|
|
268
|
+
p["x"] = random.uniform(0, WIDTH)
|
|
269
|
+
pygame.draw.circle(
|
|
270
|
+
screen,
|
|
271
|
+
(255, 255, 255),
|
|
272
|
+
(int(p["x"] + shake_x * 0.5), int(p["y"] + shake_y * 0.5)),
|
|
273
|
+
int(p["size"]),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
half_bars = BARS // 2
|
|
277
|
+
for i, value in enumerate(spectrum[:half_bars]):
|
|
278
|
+
if len(palette) == 1:
|
|
279
|
+
line_color = palette[0]
|
|
280
|
+
else:
|
|
281
|
+
t = i / max(1, half_bars - 1)
|
|
282
|
+
scaled = t * (len(palette) - 1)
|
|
283
|
+
idx = int(math.floor(scaled))
|
|
284
|
+
frac = scaled - idx
|
|
285
|
+
c1 = palette[idx]
|
|
286
|
+
c2 = palette[min(idx + 1, len(palette) - 1)]
|
|
287
|
+
line_color = (
|
|
288
|
+
int(c1[0] * (1 - frac) + c2[0] * frac),
|
|
289
|
+
int(c1[1] * (1 - frac) + c2[1] * frac),
|
|
290
|
+
int(c1[2] * (1 - frac) + c2[2] * frac),
|
|
291
|
+
)
|
|
292
|
+
line_color = brighten_color(line_color, min_brightness=100)
|
|
293
|
+
|
|
294
|
+
angle1 = (2 * math.pi / BARS) * i - math.pi / 2
|
|
295
|
+
bar_start = dynamic_radius
|
|
296
|
+
bar_len = dynamic_radius + value * 75
|
|
297
|
+
|
|
298
|
+
x1_1 = center_x + math.cos(angle1) * bar_start + shake_x
|
|
299
|
+
y1_1 = center_y + math.sin(angle1) * bar_start + shake_y
|
|
300
|
+
x2_1 = center_x + math.cos(angle1) * bar_len + shake_x
|
|
301
|
+
y2_1 = center_y + math.sin(angle1) * bar_len + shake_y
|
|
302
|
+
pygame.draw.line(screen, line_color, (x1_1, y1_1), (x2_1, y2_1), 3)
|
|
303
|
+
|
|
304
|
+
angle2 = angle1 + math.pi
|
|
305
|
+
x1_2 = center_x + math.cos(angle2) * bar_start + shake_x
|
|
306
|
+
y1_2 = center_y + math.sin(angle2) * bar_start + shake_y
|
|
307
|
+
x2_2 = center_x + math.cos(angle2) * bar_len + shake_x
|
|
308
|
+
y2_2 = center_y + math.sin(angle2) * bar_len + shake_y
|
|
309
|
+
pygame.draw.line(screen, line_color, (x1_2, y1_2), (x2_2, y2_2), 3)
|
|
310
|
+
|
|
311
|
+
rotation_angle += 0.005
|
|
312
|
+
if current_art:
|
|
313
|
+
size = int(dynamic_radius * 1.8)
|
|
314
|
+
scaled_art = pygame.transform.scale(current_art, (size, size))
|
|
315
|
+
rotated_art = pygame.transform.rotate(
|
|
316
|
+
scaled_art, -math.degrees(rotation_angle)
|
|
317
|
+
)
|
|
318
|
+
art_rect = rotated_art.get_rect(
|
|
319
|
+
center=(center_x + shake_x, center_y + shake_y)
|
|
320
|
+
)
|
|
321
|
+
screen.blit(rotated_art, art_rect)
|
|
322
|
+
|
|
323
|
+
pygame.draw.circle(
|
|
324
|
+
screen,
|
|
325
|
+
color,
|
|
326
|
+
(center_x + shake_x, center_y + shake_y),
|
|
327
|
+
int(dynamic_radius - 10),
|
|
328
|
+
2,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# === HUD ===
|
|
332
|
+
font = pygame.font.SysFont(None, 36)
|
|
333
|
+
text = font.render(f"BARS: {BARS} (UP/DOWN to change)", True, (255, 255, 255))
|
|
334
|
+
screen.blit(text, (20, 20))
|
|
335
|
+
|
|
336
|
+
pygame.display.flip()
|
|
337
|
+
clock.tick(FPS)
|
|
338
|
+
|
|
339
|
+
pygame.quit()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def main():
|
|
343
|
+
run_visualizer()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if __name__ == "__main__":
|
|
347
|
+
run_visualizer()
|
|
File without changes
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
from urllib.parse import unquote
|
|
5
|
+
from urllib.request import urlopen
|
|
6
|
+
|
|
7
|
+
from jeepney import DBusAddress, new_method_call
|
|
8
|
+
from jeepney.io.blocking import open_dbus_connection
|
|
9
|
+
from PIL import Image, ImageDraw, ImageFilter
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
_BUS = DBusAddress(
|
|
13
|
+
"/org/freedesktop/DBus",
|
|
14
|
+
bus_name="org.freedesktop.DBus",
|
|
15
|
+
interface="org.freedesktop.DBus",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _prop(conn, player, name):
|
|
20
|
+
addr = DBusAddress(
|
|
21
|
+
"/org/mpris/MediaPlayer2",
|
|
22
|
+
bus_name=player,
|
|
23
|
+
interface="org.freedesktop.DBus.Properties",
|
|
24
|
+
)
|
|
25
|
+
msg = new_method_call(addr, "Get", "ss", ("org.mpris.MediaPlayer2.Player", name))
|
|
26
|
+
return conn.send_and_get_reply(msg, timeout=2).body[0][
|
|
27
|
+
1
|
|
28
|
+
] # (signature, value) -> value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_mpris_thumbnail_bytes():
|
|
32
|
+
try:
|
|
33
|
+
with open_dbus_connection(bus="SESSION") as conn:
|
|
34
|
+
names = conn.send_and_get_reply(
|
|
35
|
+
new_method_call(_BUS, "ListNames"), timeout=2
|
|
36
|
+
).body[0]
|
|
37
|
+
players = [n for n in names if n.startswith("org.mpris.MediaPlayer2.")]
|
|
38
|
+
if not players:
|
|
39
|
+
delete_cover()
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
# предпочитаем плеер, который реально играет
|
|
43
|
+
player = next(
|
|
44
|
+
(p for p in players if _prop(conn, p, "PlaybackStatus") == "Playing"),
|
|
45
|
+
players[0],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
metadata = _prop(conn, player, "Metadata")
|
|
49
|
+
art_url = metadata.get("mpris:artUrl", ("s", ""))[1]
|
|
50
|
+
if not art_url:
|
|
51
|
+
delete_cover()
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
if art_url.startswith("file://"):
|
|
55
|
+
with open(unquote(art_url[7:]), "rb") as f:
|
|
56
|
+
return f.read()
|
|
57
|
+
if art_url.startswith("http"):
|
|
58
|
+
resp = requests.get(art_url, timeout=5)
|
|
59
|
+
resp.raise_for_status()
|
|
60
|
+
return resp.content
|
|
61
|
+
return None
|
|
62
|
+
except Exception as e:
|
|
63
|
+
print(f"[art] could not get cover: {e}")
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def delete_cover():
|
|
68
|
+
if os.path.exists("current_media_art.png"):
|
|
69
|
+
os.remove("current_media_art.png")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_circular_image(data: bytes, path="current_media_art.png"):
|
|
73
|
+
try:
|
|
74
|
+
img = Image.open(BytesIO(data))
|
|
75
|
+
|
|
76
|
+
if img.mode != "RGBA":
|
|
77
|
+
img = img.convert("RGBA")
|
|
78
|
+
|
|
79
|
+
width, height = img.size
|
|
80
|
+
|
|
81
|
+
side = min(width, height)
|
|
82
|
+
left = (width - side) // 2
|
|
83
|
+
top = (height - side) // 2
|
|
84
|
+
img_to_process = img.crop((left, top, left + side, top + side))
|
|
85
|
+
|
|
86
|
+
big_size = (side * 4, side * 4)
|
|
87
|
+
mask = Image.new("L", big_size, 0)
|
|
88
|
+
draw = ImageDraw.Draw(mask)
|
|
89
|
+
draw.ellipse((0, 0, big_size[0], big_size[1]), fill=255)
|
|
90
|
+
|
|
91
|
+
mask = mask.resize(img_to_process.size, Image.LANCZOS)
|
|
92
|
+
mask = mask.filter(ImageFilter.SMOOTH)
|
|
93
|
+
|
|
94
|
+
circular_img = Image.new("RGBA", img_to_process.size, (0, 0, 0, 0))
|
|
95
|
+
circular_img.paste(img_to_process, (0, 0), mask)
|
|
96
|
+
|
|
97
|
+
circular_img.save(path, "PNG")
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
print(f"Error saving image: {e}")
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def media_art_loop():
|
|
106
|
+
last_data = None
|
|
107
|
+
while True:
|
|
108
|
+
data = get_mpris_thumbnail_bytes()
|
|
109
|
+
if data and data != last_data:
|
|
110
|
+
if save_circular_image(data):
|
|
111
|
+
last_data = data
|
|
112
|
+
await asyncio.sleep(2.5)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: orbwave
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Real-time audio visualizer with a spinning album cover
|
|
5
|
+
Keywords: audio,visualizer,pygame,music,spectrum
|
|
6
|
+
Author: rvdxse
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
12
|
+
Requires-Dist: jeepney>=0.9.0
|
|
13
|
+
Requires-Dist: numpy>=2.5.4
|
|
14
|
+
Requires-Dist: pillow>=13.0.0
|
|
15
|
+
Requires-Dist: pygame>=2.6.1
|
|
16
|
+
Requires-Dist: requests>=2.34.2
|
|
17
|
+
Requires-Dist: soundcard>=0.4.6
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Project-URL: Repository, https://github.com/<твой-ник>/orbwave
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# orbwave
|
|
23
|
+
|
|
24
|
+
A fullscreen audio visualizer for Linux built with pygame. It listens to whatever your system is playing and draws a ring of spectrum bars around a spinning album cover.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- Captures system audio (no microphone needed), via the monitor of your default speaker
|
|
30
|
+
- Circular spectrum computed with an FFT; the ring pulses with the bass
|
|
31
|
+
- Rotating circular album cover, taken from the currently playing track
|
|
32
|
+
- Bar colors and background are generated from the cover's palette
|
|
33
|
+
- Bass-reactive screen shake and floating particles
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Linux with PulseAudio or PipeWire (with `pipewire-pulse`)
|
|
38
|
+
- Python 3.12+
|
|
39
|
+
- A media player that exposes MPRIS (Spotify, Firefox, Chromium, VLC, mpv with an MPRIS plugin, etc.) for album covers. Without it, orbwave still works, just without a cover.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install orbwave
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
or with [uv](https://docs.astral.sh/uv/):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv tool install orbwave
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
orbwave
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Start any music, and the visualizer reacts to it.
|
|
60
|
+
|
|
61
|
+
| Key | Action |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `Up` | More bars (up to 512) |
|
|
64
|
+
| `Down` | Fewer bars (down to 32) |
|
|
65
|
+
| `Esc` | Quit |
|
|
66
|
+
|
|
67
|
+
## How it works
|
|
68
|
+
|
|
69
|
+
1. A background thread records audio in chunks of 1024 samples.
|
|
70
|
+
2. Each chunk gets a Hann window and goes through `numpy.fft.rfft` to get a spectrum.
|
|
71
|
+
3. The spectrum is log-scaled and smoothed, then drawn as bars around a circle.
|
|
72
|
+
4. The lowest bins are averaged into a "bass" value that drives the pulse, shake and particle speed.
|
|
73
|
+
5. Cover art is read from the player over D-Bus (MPRIS), cropped to a circle, and used to pick the color palette.
|
|
74
|
+
|
|
75
|
+
## Troubleshooting
|
|
76
|
+
|
|
77
|
+
- **No sound reaction:** check that `pactl list sources short` shows a `.monitor` source and that your default output is the one actually playing.
|
|
78
|
+
- **No album cover:** make sure your player shows up in `playerctl -l` and provides cover art in its metadata.
|
|
79
|
+
|
|
80
|
+
## Platform support
|
|
81
|
+
|
|
82
|
+
Linux only for now. Windows and macOS are not supported yet.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
orbwave/__init__.py,sha256=LQlOH7yumMXSs0-5T2AGV9C2KZgs7zGAYhjA_uRpxxs,11715
|
|
2
|
+
orbwave/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
orbwave/utils/album_cover.py,sha256=jUcqDVwtwufm-8-Ousx6d9wEkHTiEbV7UfRnwpf693s,3423
|
|
4
|
+
orbwave-0.1.0.dist-info/licenses/LICENSE,sha256=SvHe9aDrmNmk0bFM7-cXO5IAuHORawKCt9HrBIC4QR0,1061
|
|
5
|
+
orbwave-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
6
|
+
orbwave-0.1.0.dist-info/entry_points.txt,sha256=wQ8kofQOo1SMY8kcjCKjdSROY6p8lJ8rd4PjcYf3IJQ,42
|
|
7
|
+
orbwave-0.1.0.dist-info/METADATA,sha256=wbypXkFEs--4YFIUz21qUChHGHN9o1M4olOQo_Mi7hA,2689
|
|
8
|
+
orbwave-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Kyrylo Filchenkov
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|