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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ orbwave = orbwave:main
3
+
@@ -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.