cloudspin 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.
cloudspin/songlook.py ADDED
@@ -0,0 +1,116 @@
1
+ """The look of a song: a background and two accents pulled from its cover art.
2
+
3
+ Songs without art still get a look of their own, derived from the album or
4
+ title, so the deck changes colour whenever the music does.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import colorsys
10
+ import hashlib
11
+ import io
12
+ from dataclasses import dataclass
13
+
14
+ from .color import RGB
15
+
16
+ THUMB = 24 # the cover is kept as a THUMB x THUMB swatch for the record label
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SongLook:
21
+ background: RGB
22
+ accent: RGB
23
+ accent2: RGB
24
+ art: bytes | None = None # THUMB * THUMB * 3 RGB bytes
25
+
26
+
27
+ def _rgb(h: float, s: float, v: float) -> RGB:
28
+ r, g, b = colorsys.hsv_to_rgb(h % 1.0, max(0.0, min(1.0, s)), max(0.0, min(1.0, v)))
29
+ return (int(r * 255 + 0.5), int(g * 255 + 0.5), int(b * 255 + 0.5))
30
+
31
+
32
+ def _hsv(c: RGB) -> tuple[float, float, float]:
33
+ return colorsys.rgb_to_hsv(c[0] / 255.0, c[1] / 255.0, c[2] / 255.0)
34
+
35
+
36
+ def _hue_gap(a: float, b: float) -> float:
37
+ d = abs(a - b) % 1.0
38
+ return min(d, 1.0 - d)
39
+
40
+
41
+ def _as_accent(c: RGB, floor: float = 0.84) -> RGB:
42
+ """Keep a colour's hue but make it bright and calm enough to read on a dark deck."""
43
+ h, s, v = _hsv(c)
44
+ return _rgb(h, min(max(s, 0.36), 0.80), min(max(v, floor), 0.98))
45
+
46
+
47
+ def from_text(text: str) -> SongLook:
48
+ digest = hashlib.sha1(text.encode("utf-8", "replace")).digest()
49
+ hue = digest[0] / 256.0 + digest[1] / 65536.0
50
+ turn = 0.08 + (digest[2] / 255.0) * 0.10
51
+ return SongLook(
52
+ background=_rgb(hue, 0.50, 0.125),
53
+ accent=_rgb(hue, 0.52, 0.96),
54
+ accent2=_rgb(hue + turn, 0.58, 0.92),
55
+ )
56
+
57
+
58
+ def from_art(data: bytes) -> SongLook | None:
59
+ """Read a cover image and pick colours from it. None if it can't be decoded."""
60
+ try:
61
+ from PIL import Image
62
+ except ImportError:
63
+ return None
64
+ try:
65
+ image = Image.open(io.BytesIO(data))
66
+ image.draft("RGB", (160, 160))
67
+ image = image.convert("RGB")
68
+ art = image.resize((THUMB, THUMB), Image.Resampling.LANCZOS).tobytes()
69
+ small = image.resize((40, 40), Image.Resampling.BILINEAR)
70
+ quantized = small.quantize(colors=8, method=Image.Quantize.MEDIANCUT)
71
+ palette = quantized.getpalette() or []
72
+ counts = quantized.getcolors() or []
73
+ except Exception:
74
+ return None
75
+ total = float(sum(count for count, _ in counts)) or 1.0
76
+ swatches: list[tuple[float, RGB]] = []
77
+ for count, index in counts:
78
+ rgb = tuple(palette[index * 3 : index * 3 + 3])
79
+ if len(rgb) == 3:
80
+ swatches.append((count / total, (int(rgb[0]), int(rgb[1]), int(rgb[2]))))
81
+ if not swatches:
82
+ return None
83
+
84
+ def vivid(entry: tuple[float, RGB]) -> float:
85
+ share, colour = entry
86
+ _, s, v = _hsv(colour)
87
+ if s < 0.16 or v < 0.16:
88
+ return 0.0
89
+ return (share**0.35) * s * (0.35 + v)
90
+
91
+ dominant = max(swatches, key=lambda entry: entry[0])[1]
92
+ ranked = sorted(swatches, key=vivid, reverse=True)
93
+ if vivid(ranked[0]) <= 0.0:
94
+ # A black-and-white cover: stay monochrome.
95
+ return SongLook(background=(14, 14, 17), accent=(228, 230, 236), accent2=(152, 158, 172), art=art)
96
+ first = ranked[0][1]
97
+ hue = _hsv(first)[0]
98
+ second = next(
99
+ (colour for entry in ranked[1:] if vivid(entry) > 0.0 and _hue_gap(_hsv((colour := entry[1]))[0], hue) >= 0.07),
100
+ None,
101
+ )
102
+ accent = _as_accent(first)
103
+ accent2 = _as_accent(second, 0.78) if second is not None else _rgb(hue + 0.09, 0.58, 0.90)
104
+ dh, ds, dv = _hsv(dominant)
105
+ if ds < 0.14:
106
+ dh, ds = hue, 0.22
107
+ background = _rgb(dh, min(ds, 0.62) * 0.9, 0.125)
108
+ return SongLook(background=background, accent=accent, accent2=accent2, art=art)
109
+
110
+
111
+ def look_for(title: str, artist: str, album: str, art: bytes | None) -> SongLook:
112
+ if art:
113
+ look = from_art(art)
114
+ if look is not None:
115
+ return look
116
+ return from_text(f"{artist}\n{album}" if (artist or album) else title)
cloudspin/spectrum.py ADDED
@@ -0,0 +1,83 @@
1
+ """The bars beside the record.
2
+
3
+ This is a generative spectrum, not an FFT of the audio: it is driven by the
4
+ play state, the volume and a tempo picked per song, so it moves like music
5
+ without needing access to the sound card.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import random
12
+
13
+
14
+ class Spectrum:
15
+ def __init__(self) -> None:
16
+ self.levels: list[float] = []
17
+ self.peaks: list[float] = []
18
+ self.energy = 0.0
19
+ self._t = 0.0
20
+ self._tempo = 110.0
21
+ self._phases: list[tuple[float, float, float]] = []
22
+ self._rng = random.Random(1)
23
+ self._noise: list[float] = []
24
+
25
+ def resize(self, bars: int) -> None:
26
+ bars = max(0, bars)
27
+ if bars == len(self.levels):
28
+ return
29
+ self.levels = [0.0] * bars
30
+ self.peaks = [0.0] * bars
31
+ self._noise = [0.0] * bars
32
+ self._seed_phases()
33
+
34
+ def retune(self, seed: str, tempo: float | None = None) -> None:
35
+ """Give a song its own motion."""
36
+ self._rng = random.Random(seed)
37
+ self._tempo = tempo if tempo and 50 <= tempo <= 220 else self._rng.uniform(84, 132)
38
+ self._seed_phases()
39
+
40
+ def _seed_phases(self) -> None:
41
+ rng = self._rng
42
+ self._phases = [
43
+ (rng.uniform(0, math.tau), rng.uniform(0.7, 2.6), rng.uniform(0, math.tau)) for _ in self.levels
44
+ ]
45
+
46
+ def step(self, dt: float, playing: bool, volume: float) -> None:
47
+ bars = len(self.levels)
48
+ if not bars:
49
+ self.energy *= 0.8
50
+ return
51
+ self._t += dt
52
+ t = self._t
53
+ beat = (t * self._tempo / 60.0) % 1.0
54
+ kick = math.exp(-5.0 * beat)
55
+ offbeat = math.exp(-7.0 * ((beat + 0.5) % 1.0))
56
+ gain = (0.35 + 0.65 * min(1.0, volume)) if playing else 0.0
57
+ rng = self._rng
58
+ total = 0.0
59
+ for i in range(bars):
60
+ u = i / max(1, bars - 1)
61
+ a, rate, b = self._phases[i]
62
+ self._noise[i] += (rng.random() - self._noise[i]) * min(1.0, dt * 9.0)
63
+ shape = 0.92 - 0.55 * u**0.8
64
+ body = 0.5 + 0.5 * math.sin(t * rate + a) * math.sin(t * 0.37 * rate + b)
65
+ low = kick * max(0.0, 1.0 - u * 3.2)
66
+ mid = offbeat * max(0.0, 1.0 - abs(u - 0.45) * 3.0) * 0.6
67
+ target = gain * min(1.0, shape * (0.26 + 0.62 * body + 0.34 * self._noise[i]) + 0.6 * low + mid * 0.55)
68
+ level = self.levels[i]
69
+ if target > level:
70
+ level += (target - level) * min(1.0, dt * 22.0)
71
+ else:
72
+ level += (target - level) * min(1.0, dt * 6.5)
73
+ self.levels[i] = level
74
+ peak = self.peaks[i] - dt * 0.38
75
+ self.peaks[i] = level if level > peak else max(0.0, peak)
76
+ if u < 0.3:
77
+ total += level
78
+ low_bars = max(1, int(bars * 0.3) + 1)
79
+ self.energy += (min(1.0, total / low_bars * 1.3) - self.energy) * min(1.0, dt * 14.0)
80
+
81
+ @property
82
+ def still(self) -> bool:
83
+ return self.energy < 0.004 and all(p < 0.004 for p in self.peaks)
cloudspin/themes.py ADDED
@@ -0,0 +1,100 @@
1
+ """Colour for everything that isn't the sky.
2
+
3
+ Two themes are alive: ``sky`` takes its colours from whatever the sky is doing,
4
+ and ``cover`` takes them from the playing song's cover art. The rest are fixed
5
+ palettes, and you can add your own under ``[themes.<name>]`` in the config.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from .color import RGB, approach, lerp, parse
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Theme:
17
+ name: str
18
+ label: str
19
+ accent: RGB | None = None
20
+ accent2: RGB | None = None
21
+ background: RGB | None = None
22
+ disc: RGB | None = None # record label, defaults to the accent
23
+ disc2: RGB | None = None
24
+
25
+
26
+ BUILT_IN: dict[str, Theme] = {
27
+ theme.name: theme
28
+ for theme in (
29
+ Theme("sky", "Colours follow the sky"),
30
+ Theme("cover", "Colours follow the cover art"),
31
+ Theme("amber", "Amber", (255, 184, 82), (255, 112, 84), (20, 14, 10)),
32
+ Theme("orchid", "Orchid", (214, 150, 255), (255, 120, 190), (17, 11, 24)),
33
+ Theme("lagoon", "Lagoon", (84, 214, 206), (96, 160, 255), (8, 17, 24)),
34
+ Theme("moss", "Moss", (170, 220, 104), (72, 196, 150), (10, 17, 13)),
35
+ Theme("rose", "Rose", (255, 140, 150), (255, 196, 140), (22, 12, 15)),
36
+ Theme("mono", "Mono", (232, 232, 232), (140, 140, 140), (12, 12, 12)),
37
+ )
38
+ }
39
+
40
+
41
+ def load_themes(custom: dict[str, dict[str, str]]) -> dict[str, Theme]:
42
+ """Built-in themes plus any valid ones from the config file."""
43
+ themes = dict(BUILT_IN)
44
+ for name, table in custom.items():
45
+ if name in ("sky", "cover"):
46
+ continue
47
+ try:
48
+ colours = {
49
+ key: parse(table[key])
50
+ for key in ("accent", "accent2", "background", "disc", "disc2")
51
+ if key in table
52
+ }
53
+ except ValueError:
54
+ continue
55
+ if "accent" not in colours:
56
+ continue
57
+ colours.setdefault("accent2", colours["accent"])
58
+ themes[name] = Theme(name=name, label=name.capitalize(), **colours)
59
+ return themes
60
+
61
+
62
+ @dataclass
63
+ class Palette:
64
+ """The colours every widget paints with, resolved for this moment."""
65
+
66
+ bg: RGB = (10, 14, 24)
67
+ accent: RGB = (255, 202, 98)
68
+ accent2: RGB = (112, 182, 255)
69
+ disc: RGB = (255, 202, 98)
70
+ disc2: RGB = (112, 182, 255)
71
+
72
+ @property
73
+ def text(self) -> RGB:
74
+ return (238, 241, 246)
75
+
76
+ @property
77
+ def soft(self) -> RGB:
78
+ return lerp(self.bg, self.text, 0.62)
79
+
80
+ @property
81
+ def faint(self) -> RGB:
82
+ return lerp(self.bg, self.text, 0.34)
83
+
84
+ @property
85
+ def raised(self) -> RGB:
86
+ return lerp(self.bg, self.text, 0.085)
87
+
88
+ @property
89
+ def lifted(self) -> RGB:
90
+ return lerp(self.bg, self.text, 0.16)
91
+
92
+ def key(self) -> tuple:
93
+ return (self.bg, self.accent, self.accent2, self.disc, self.disc2)
94
+
95
+ def ease_to(self, target: Palette, k: float) -> None:
96
+ self.bg = approach(self.bg, target.bg, k)
97
+ self.accent = approach(self.accent, target.accent, k)
98
+ self.accent2 = approach(self.accent2, target.accent2, k)
99
+ self.disc = approach(self.disc, target.disc, k)
100
+ self.disc2 = approach(self.disc2, target.disc2, k)
cloudspin/vinyl.py ADDED
@@ -0,0 +1,182 @@
1
+ """The record: grooves that catch the light, a label that turns, a tonearm that tracks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from .canvas import Canvas
8
+ from .color import RGB, lerp
9
+ from .songlook import THUMB
10
+
11
+ RPM = 33.333
12
+ _LIGHT = math.radians(-38.0) # where the sheen sits on the grooves
13
+
14
+
15
+ class Vinyl:
16
+ def __init__(self) -> None:
17
+ self.w = 0
18
+ self.h = 0
19
+ self.angle = 0.0
20
+ self.speed = 0.0 # 0 stopped .. 1 full speed
21
+ self.reach = 1.16 # tonearm head, in disc radii from the centre
22
+ self.cx = 0.0
23
+ self.cy = 0.0
24
+ self.radius = 0.0
25
+ self._groove: list[tuple[int, int, int, float, float, int]] = []
26
+ self._label: list[tuple[int, float, float, float, int]] = []
27
+ self._halo: list[tuple[int, int]] = []
28
+ self._pivot = (0.0, 0.0)
29
+ self._arm = 0.0
30
+ self._base = 0.0
31
+
32
+ @staticmethod
33
+ def width_for(pixel_height: int) -> int:
34
+ """How many columns a record of this height needs, tonearm included."""
35
+ radius = (pixel_height - 2) / 2.0
36
+ return int(radius * 2.36) + 6
37
+
38
+ def resize(self, w: int, h: int) -> None:
39
+ if (w, h) == (self.w, self.h):
40
+ return
41
+ self.w, self.h = w, h
42
+ radius = max(3.0, min((h - 2) / 2.0, (w - 6) / 2.36))
43
+ self.radius = radius
44
+ self.cx = radius + 2.0
45
+ self.cy = (h - 1) / 2.0
46
+ cx, cy = self.cx, self.cy
47
+ groove, label, halo = [], [], []
48
+ label_edge = 0.36
49
+ gaps = (0.53, 0.69, 0.84)
50
+ for y in range(h):
51
+ for x in range(w):
52
+ dx, dy = x - cx, y - cy
53
+ dist = math.hypot(dx, dy)
54
+ d = dist / radius
55
+ i = y * w + x
56
+ if d > 1.0 + 0.5 / radius:
57
+ fall = 1.0 - (d - 1.0) / 0.55
58
+ if fall > 0.0:
59
+ halo.append((i, int(fall * fall * 256)))
60
+ continue
61
+ theta = math.atan2(dy, dx)
62
+ if d <= label_edge + 0.5 / radius:
63
+ cover = min(1.0, (label_edge - d) * radius + 0.5)
64
+ label.append((i, dx / (label_edge * radius), dy / (label_edge * radius), d, int(cover * 256)))
65
+ if cover >= 1.0:
66
+ continue
67
+ edge = min(1.0, (1.0 - d) * radius + 0.5)
68
+ base = 15.0 + 6.0 * math.sin(dist * 2.3)
69
+ for gap in gaps:
70
+ if abs(d - gap) < 0.55 / radius:
71
+ base -= 7.0
72
+ if d > 0.955:
73
+ base += 16.0
74
+ if d < label_edge + 1.2 / radius:
75
+ base -= 5.0
76
+ wedge = max(0.0, math.cos(2.0 * (theta - _LIGHT))) ** 14
77
+ sheen = wedge * (0.62 + 0.38 * math.sin(math.pi * min(1.0, max(0.0, (d - 0.36) / 0.64))))
78
+ phase = 3.0 * theta + d * 9.0
79
+ groove.append((i, int(base), int(sheen * 256), math.sin(phase), math.cos(phase), int(edge * 256)))
80
+ self._groove, self._label, self._halo = groove, label, halo
81
+ self._pivot = (cx + radius * 1.0, cy - radius * 0.84)
82
+ self._arm = radius * 1.25
83
+ self._base = math.hypot(self._pivot[0] - cx, self._pivot[1] - cy)
84
+
85
+ def step(self, dt: float, spinning: bool, loaded: bool, progress: float) -> None:
86
+ goal = 1.0 if spinning else 0.0
87
+ rate = 0.45 if spinning else 0.9
88
+ self.speed += (goal - self.speed) * (1.0 - math.exp(-dt / rate))
89
+ if abs(goal - self.speed) < 0.004:
90
+ self.speed = goal
91
+ self.angle = (self.angle + self.speed * (RPM / 60.0) * math.tau * dt) % math.tau
92
+ reach = 0.93 - 0.50 * max(0.0, min(1.0, progress)) if loaded else 1.16
93
+ self.reach += (reach - self.reach) * (1.0 - math.exp(-dt / 0.3))
94
+
95
+ @property
96
+ def moving(self) -> bool:
97
+ return self.speed > 0.0
98
+
99
+ def render(
100
+ self, cv: Canvas, bg: RGB, label: RGB, label2: RGB, glow: RGB, pulse: float, art: bytes | None = None
101
+ ) -> None:
102
+ if cv.w != self.w or cv.h != self.h or not self._groove:
103
+ return
104
+ cv.fill(bg)
105
+ R, G, B = cv.r, cv.g, cv.b
106
+ br, bg_, bb = bg
107
+
108
+ strength = 0.07 + 0.2 * pulse
109
+ hr, hg, hb = int(glow[0] * strength), int(glow[1] * strength), int(glow[2] * strength)
110
+ for i, fall in self._halo:
111
+ R[i] = min(255, br + (hr * fall >> 8))
112
+ G[i] = min(255, bg_ + (hg * fall >> 8))
113
+ B[i] = min(255, bb + (hb * fall >> 8))
114
+
115
+ turn = 3.0 * self.angle
116
+ cos_t, sin_t = math.cos(turn), math.sin(turn)
117
+ tr, tg, tb = glow[0] * 0.30, glow[1] * 0.30, glow[2] * 0.30
118
+ for i, base, sheen, s3, c3, edge in self._groove:
119
+ shimmer = s3 * cos_t - c3 * sin_t
120
+ v = base + shimmer * (2.5 + sheen * 0.085) + sheen * 0.26
121
+ r = v + (tr * sheen) / 256.0
122
+ g = v + (tg * sheen) / 256.0
123
+ b = v + 2.0 + (tb * sheen) / 256.0
124
+ if edge < 256:
125
+ r = br + (r - br) * edge / 256.0
126
+ g = bg_ + (g - bg_) * edge / 256.0
127
+ b = bb + (b - bb) * edge / 256.0
128
+ R[i] = 0 if r < 0 else 255 if r > 255 else int(r)
129
+ G[i] = 0 if g < 0 else 255 if g > 255 else int(g)
130
+ B[i] = 0 if b < 0 else 255 if b > 255 else int(b)
131
+
132
+ ca, sa = math.cos(self.angle), math.sin(self.angle)
133
+ hole = max(0.05, 1.0 / self.radius)
134
+ ring = hole * 2.0
135
+ dark = (10, 10, 13)
136
+ metal = (176, 180, 190)
137
+ stripe = lerp(label2, (255, 255, 255), 0.1)
138
+ rim = lerp(label, (0, 0, 0), 0.32)
139
+ half = THUMB / 2.0
140
+ for i, nx, ny, d, cover in self._label:
141
+ u = nx * ca + ny * sa
142
+ v = -nx * sa + ny * ca
143
+ if d < hole:
144
+ tone = dark
145
+ elif d < ring:
146
+ tone = metal
147
+ elif art is not None:
148
+ ax = min(THUMB - 1, max(0, int((u * 0.98 + 1.0) * half)))
149
+ ay = min(THUMB - 1, max(0, int((v * 0.98 + 1.0) * half)))
150
+ k = (ay * THUMB + ax) * 3
151
+ tone = (art[k], art[k + 1], art[k + 2])
152
+ if u * u + v * v > 0.86:
153
+ tone = lerp(tone, (0, 0, 0), 0.35)
154
+ elif u * u + v * v > 0.84:
155
+ tone = rim
156
+ elif abs(v) < 0.24 and abs(u) > 0.3:
157
+ tone = stripe
158
+ else:
159
+ tone = label
160
+ if cover >= 256:
161
+ R[i], G[i], B[i] = tone
162
+ else:
163
+ R[i] += (tone[0] - R[i]) * cover >> 8
164
+ G[i] += (tone[1] - G[i]) * cover >> 8
165
+ B[i] += (tone[2] - B[i]) * cover >> 8
166
+
167
+ self._paint_arm(cv, glow)
168
+
169
+ def _paint_arm(self, cv: Canvas, accent: RGB) -> None:
170
+ px, py = self._pivot
171
+ radius, arm, base = self.radius, self._arm, self._base
172
+ rho = self.reach * radius
173
+ cos_a = max(-1.0, min(1.0, (base * base + arm * arm - rho * rho) / (2.0 * base * arm)))
174
+ heading = math.atan2(self.cy - py, self.cx - px) - math.acos(cos_a)
175
+ ux, uy = math.cos(heading), math.sin(heading)
176
+ hx, hy = px + ux * arm, py + uy * arm
177
+ thick = max(1.0, radius * 0.075)
178
+ cv.line(px - ux * radius * 0.2, py - uy * radius * 0.2, px, py, (112, 116, 128), 256, thick * 2.2)
179
+ cv.line(px, py, hx, hy, (204, 208, 218), 256, thick)
180
+ cv.line(hx - ux * radius * 0.17, hy - uy * radius * 0.17, hx, hy, accent, 256, thick * 2.3)
181
+ cv.disc(px, py, max(1.4, radius * 0.1), (148, 152, 164))
182
+ cv.disc(px, py, max(0.5, radius * 0.04), (232, 234, 240))