daneq 0.4.2__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.
daneq/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DanEQ: a terminal audio equalizer and spectrum visualizer."""
2
+
3
+ __version__ = "0.4.2"
daneq/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
daneq/audio.py ADDED
@@ -0,0 +1,493 @@
1
+ """Audio plumbing built on pactl/parec/pacat (works on PulseAudio and PipeWire-Pulse)."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ import wave
9
+ from typing import List, Optional, Tuple
10
+
11
+ import numpy as np
12
+
13
+ VSINK = "daneq"
14
+
15
+
16
+ class AudioError(RuntimeError):
17
+ pass
18
+
19
+
20
+ # ---- pactl helpers ---------------------------------------------------------
21
+ def have_pulse_tools() -> bool:
22
+ return all(shutil.which(t) for t in ("pactl", "parec", "pacat"))
23
+
24
+
25
+ def pactl(*args: str) -> str:
26
+ try:
27
+ r = subprocess.run(["pactl", *args], capture_output=True, text=True, timeout=10)
28
+ except FileNotFoundError:
29
+ raise AudioError("pactl not found (install pulseaudio-utils / pipewire-pulse)")
30
+ except subprocess.TimeoutExpired:
31
+ raise AudioError("pactl timed out (is the sound server running?)")
32
+ if r.returncode != 0:
33
+ raise AudioError(r.stderr.strip() or f"pactl {' '.join(args)} failed")
34
+ return r.stdout
35
+
36
+
37
+ def default_sink() -> Optional[str]:
38
+ for line in pactl("info").splitlines():
39
+ if line.startswith("Default Sink:"):
40
+ return line.split(":", 1)[1].strip() or None
41
+ return None
42
+
43
+
44
+ def list_sinks() -> List[Tuple[int, str]]:
45
+ out = []
46
+ for line in pactl("list", "short", "sinks").splitlines():
47
+ p = line.split("\t")
48
+ if len(p) >= 2:
49
+ out.append((int(p[0]), p[1]))
50
+ return out
51
+
52
+
53
+ def sink_inputs() -> List[Tuple[int, int]]:
54
+ out = []
55
+ for line in pactl("list", "short", "sink-inputs").splitlines():
56
+ p = line.split("\t")
57
+ if len(p) >= 2 and p[0].isdigit() and p[1].isdigit():
58
+ out.append((int(p[0]), int(p[1])))
59
+ return out
60
+
61
+
62
+ def cleanup_stale() -> None:
63
+ """Unload any leftover DanEQ null sinks (e.g. after a crash)."""
64
+ try:
65
+ for line in pactl("list", "short", "modules").splitlines():
66
+ p = line.split("\t")
67
+ if len(p) >= 3 and p[1] == "module-null-sink" and f"sink_name={VSINK}" in p[2]:
68
+ pactl("unload-module", p[0])
69
+ except AudioError:
70
+ pass
71
+
72
+
73
+ def restore() -> str:
74
+ """Undo a crashed session: drop the virtual sink and fix the default sink."""
75
+ cleanup_stale()
76
+ cur = default_sink()
77
+ if cur is None or cur.startswith(VSINK):
78
+ for _, name in list_sinks():
79
+ if not name.startswith(VSINK):
80
+ pactl("set-default-sink", name)
81
+ return name
82
+ return ""
83
+ return cur
84
+
85
+
86
+ class VirtualSink:
87
+ """Creates the 'daneq' null sink and makes it the default output.
88
+
89
+ Apps play into it, DanEQ records its monitor, equalizes, and plays the
90
+ result to the real output. Everything is undone on close().
91
+ """
92
+
93
+ def __init__(self, rate: int, target: Optional[str] = None):
94
+ self.rate = rate
95
+ self.target = target
96
+ self.orig: Optional[str] = None
97
+ self.module: Optional[int] = None
98
+
99
+ @property
100
+ def monitor(self) -> str:
101
+ return f"{VSINK}.monitor"
102
+
103
+ def open(self) -> str:
104
+ cleanup_stale()
105
+ self.orig = default_sink()
106
+ if self.target is None:
107
+ self.target = self.orig
108
+ if not self.target:
109
+ raise AudioError("no output sink found (is a sound server running?)")
110
+ out = pactl("load-module", "module-null-sink", f"sink_name={VSINK}",
111
+ f"rate={self.rate}", "channels=2",
112
+ "sink_properties=device.description=DanEQ")
113
+ try:
114
+ self.module = int(out.strip())
115
+ except ValueError:
116
+ raise AudioError(f"unexpected module id: {out!r}")
117
+ idx = dict((n, i) for i, n in list_sinks()).get(VSINK)
118
+ for inp, sink in sink_inputs():
119
+ if sink != idx:
120
+ try:
121
+ pactl("move-sink-input", str(inp), VSINK)
122
+ except AudioError:
123
+ pass
124
+ pactl("set-default-sink", VSINK)
125
+ return self.target
126
+
127
+ def close(self) -> None:
128
+ if self.module is None:
129
+ return
130
+ try:
131
+ if self.orig and not self.orig.startswith(VSINK):
132
+ pactl("set-default-sink", self.orig)
133
+ idx = dict((n, i) for i, n in list_sinks()).get(VSINK)
134
+ if idx is not None and self.orig:
135
+ for inp, sink in sink_inputs():
136
+ if sink == idx:
137
+ try:
138
+ pactl("move-sink-input", str(inp), self.orig)
139
+ except AudioError:
140
+ pass
141
+ pactl("unload-module", str(self.module))
142
+ except AudioError:
143
+ pass
144
+ self.module = None
145
+
146
+
147
+ # ---- sources ---------------------------------------------------------------
148
+ class GenSource:
149
+ """Wraps a generator of float32 (n, 2) blocks."""
150
+
151
+ def __init__(self, gen):
152
+ self._gen = gen
153
+
154
+ def __iter__(self):
155
+ return self._gen
156
+
157
+ def close(self) -> None:
158
+ pass
159
+
160
+
161
+ class ParecSource:
162
+ """Live capture through parec (raw float32 stereo on stdout)."""
163
+
164
+ def __init__(self, device: str, rate: int, block: int, latency_ms: int = 20):
165
+ self.block = block
166
+ self.proc = subprocess.Popen(
167
+ ["parec", f"--device={device}", f"--rate={rate}", "--channels=2",
168
+ "--format=float32le", f"--latency-msec={latency_ms}",
169
+ "--client-name=DanEQ", "--stream-name=capture"],
170
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
171
+
172
+ def __iter__(self):
173
+ n = self.block * 8
174
+ while True:
175
+ buf = self.proc.stdout.read(n)
176
+ if len(buf) < n:
177
+ raise AudioError("capture stream ended (sound server went away?)")
178
+ yield np.frombuffer(buf, dtype="<f4").reshape(-1, 2).copy()
179
+
180
+ def close(self) -> None:
181
+ _kill(self.proc)
182
+
183
+
184
+ class FfmpegSource:
185
+ def __init__(self, path: str, rate: int, block: int, loop: bool = False):
186
+ self.path, self.rate, self.block, self.loop = path, rate, block, loop
187
+ self.proc = None
188
+
189
+ def __iter__(self):
190
+ n = self.block * 8
191
+ while True:
192
+ self.proc = subprocess.Popen(
193
+ ["ffmpeg", "-v", "quiet", "-i", self.path, "-vn", "-f", "f32le",
194
+ "-ac", "2", "-ar", str(self.rate), "-"],
195
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
196
+ got = 0
197
+ while True:
198
+ buf = self.proc.stdout.read(n)
199
+ if not buf:
200
+ break
201
+ buf = buf[: len(buf) // 8 * 8]
202
+ got += len(buf)
203
+ if buf:
204
+ yield np.frombuffer(buf, dtype="<f4").reshape(-1, 2).copy()
205
+ _kill(self.proc)
206
+ if not self.loop or got == 0:
207
+ return
208
+
209
+ def close(self) -> None:
210
+ if self.proc:
211
+ _kill(self.proc)
212
+
213
+
214
+ class WavSource:
215
+ """Stdlib-only fallback for 16-bit PCM WAV when ffmpeg is missing."""
216
+
217
+ def __init__(self, path: str, block: int, loop: bool = False):
218
+ self.path, self.block, self.loop = path, block, loop
219
+ with wave.open(path, "rb") as w:
220
+ self.rate = w.getframerate()
221
+ if w.getsampwidth() != 2:
222
+ raise AudioError("only 16-bit WAV is supported without ffmpeg")
223
+
224
+ def __iter__(self):
225
+ while True:
226
+ with wave.open(self.path, "rb") as w:
227
+ ch = w.getnchannels()
228
+ while True:
229
+ raw = w.readframes(self.block)
230
+ if not raw:
231
+ break
232
+ x = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
233
+ x = x.reshape(-1, ch)
234
+ x = np.repeat(x, 2, axis=1) if ch == 1 else x[:, :2]
235
+ yield np.ascontiguousarray(x)
236
+ if not self.loop:
237
+ return
238
+
239
+ def close(self) -> None:
240
+ pass
241
+
242
+
243
+ class SoundfileSource:
244
+ """Decoder used when ffmpeg is missing but the optional 'soundfile' package is installed."""
245
+
246
+ def __init__(self, path: str, block: int, loop: bool = False):
247
+ import soundfile as sf
248
+ self.sf, self.path, self.block, self.loop = sf, path, block, loop
249
+ with sf.SoundFile(path) as f:
250
+ self.rate = f.samplerate
251
+
252
+ def __iter__(self):
253
+ while True:
254
+ with self.sf.SoundFile(self.path) as f:
255
+ while True:
256
+ x = f.read(self.block, dtype="float32", always_2d=True)
257
+ if len(x) == 0:
258
+ break
259
+ x = np.repeat(x, 2, axis=1) if x.shape[1] == 1 else x[:, :2]
260
+ yield np.ascontiguousarray(x)
261
+ if not self.loop:
262
+ return
263
+
264
+ def close(self) -> None:
265
+ pass
266
+
267
+
268
+ def open_file(path: str, rate: int, block: int, loop: bool = False):
269
+ """Returns (source, rate). Tries ffmpeg, then the optional soundfile package, then stdlib WAV."""
270
+ if shutil.which("ffmpeg"):
271
+ return FfmpegSource(path, rate, block, loop), rate
272
+ try:
273
+ src = SoundfileSource(path, block, loop)
274
+ return src, src.rate
275
+ except ImportError:
276
+ pass
277
+ except Exception as exc: # noqa: BLE001 - unreadable by libsndfile, try WAV next
278
+ if not path.lower().endswith(".wav"):
279
+ raise AudioError(f"cannot decode {path}: {exc}")
280
+ if path.lower().endswith(".wav"):
281
+ src = WavSource(path, block, loop)
282
+ return src, src.rate
283
+ raise AudioError("no decoder for this file type: install ffmpeg, or 'pip install daneq[audio]' (WAV always works)")
284
+
285
+
286
+ # ---- sinks -----------------------------------------------------------------
287
+ class PacatSink:
288
+ def __init__(self, device: Optional[str], rate: int, latency_ms: int = 30):
289
+ cmd = ["pacat", "--playback", f"--rate={rate}", "--channels=2",
290
+ "--format=float32le", f"--latency-msec={latency_ms}",
291
+ "--client-name=DanEQ", "--stream-name=output"]
292
+ if device:
293
+ cmd.append(f"--device={device}")
294
+ self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)
295
+
296
+ def write(self, data: bytes) -> None:
297
+ try:
298
+ self.proc.stdin.write(data)
299
+ self.proc.stdin.flush()
300
+ except (BrokenPipeError, ValueError):
301
+ raise AudioError("playback stream closed (sound server went away?)")
302
+
303
+ def close(self) -> None:
304
+ _kill(self.proc)
305
+
306
+
307
+ class NullSink:
308
+ """Discards audio but paces the engine in real time (for --no-output)."""
309
+
310
+ def __init__(self, rate: int):
311
+ self.rate = rate
312
+ self._next = None
313
+
314
+ def write(self, data: bytes) -> None:
315
+ frames = len(data) // 8
316
+ now = time.perf_counter()
317
+ if self._next is None:
318
+ self._next = now
319
+ self._next += frames / self.rate
320
+ delay = self._next - now
321
+ if delay > 0:
322
+ time.sleep(delay)
323
+ elif delay < -1.0:
324
+ self._next = now
325
+
326
+ def close(self) -> None:
327
+ pass
328
+
329
+
330
+ def _kill(proc: subprocess.Popen) -> None:
331
+ try:
332
+ proc.terminate()
333
+ proc.wait(timeout=1.0)
334
+ except Exception: # noqa: BLE001
335
+ try:
336
+ proc.kill()
337
+ except Exception: # noqa: BLE001
338
+ pass
339
+
340
+
341
+ # ---- PortAudio backend (macOS via BlackHole/Loopback, also usable elsewhere) ---------------
342
+ VIRTUAL_HINTS = ("blackhole", "loopback", "soundflower", "vb-cable", "vb-audio", "background music", "eqmac", "daneq")
343
+
344
+
345
+ def have_portaudio() -> bool:
346
+ try:
347
+ import sounddevice # noqa: F401
348
+ return True
349
+ except Exception: # noqa: BLE001 - missing module or missing PortAudio library
350
+ return False
351
+
352
+
353
+ def _sd():
354
+ try:
355
+ import sounddevice as sd
356
+ except Exception as exc: # noqa: BLE001
357
+ raise AudioError("the PortAudio backend needs the 'sounddevice' package: pip install sounddevice "
358
+ f"({exc})")
359
+ return sd
360
+
361
+
362
+ def list_devices() -> list:
363
+ """[(index, name, max_in, max_out, default_rate, is_virtual)]"""
364
+ sd = _sd()
365
+ out = []
366
+ for i, d in enumerate(sd.query_devices()):
367
+ name = d["name"]
368
+ out.append((i, name, d["max_input_channels"], d["max_output_channels"],
369
+ int(d["default_samplerate"]), any(h in name.lower() for h in VIRTUAL_HINTS)))
370
+ return out
371
+
372
+
373
+ def find_device(match, kind: str):
374
+ """Pick a device index by case-insensitive name substring (or number) for 'input' or 'output'."""
375
+ devs = list_devices()
376
+ col = 2 if kind == "input" else 3
377
+ if match is not None and str(match).isdigit():
378
+ for d in devs:
379
+ if d[0] == int(match) and d[col] >= 1:
380
+ return d
381
+ if match is not None:
382
+ for d in devs:
383
+ if str(match).lower() in d[1].lower() and d[col] >= 1:
384
+ return d
385
+ raise AudioError(f"no {kind} device matching '{match}'. Try --list-devices")
386
+ return None
387
+
388
+
389
+ def pick_loopback():
390
+ """Capture device for system audio: a virtual loopback driver such as BlackHole."""
391
+ for d in list_devices():
392
+ if d[5] and d[2] >= 2:
393
+ return d
394
+ return None
395
+
396
+
397
+ def pick_output(avoid_virtual: bool = True):
398
+ """Real output: the system default unless it is virtual, else the first real stereo output."""
399
+ sd = _sd()
400
+ devs = list_devices()
401
+ try:
402
+ idx = sd.default.device[1]
403
+ if idx is not None and idx >= 0:
404
+ d = devs[idx]
405
+ if not (avoid_virtual and d[5]):
406
+ return d
407
+ except Exception: # noqa: BLE001
408
+ pass
409
+ real = [d for d in devs if d[3] >= 2 and not d[5]]
410
+ pref = [d for d in real if any(k in d[1].lower() for k in ("speaker", "headphone", "built-in", "output"))]
411
+ return (pref or real or [None])[0]
412
+
413
+
414
+ class SdSource:
415
+ """Live capture through PortAudio."""
416
+
417
+ def __init__(self, device: int, rate: int, block: int):
418
+ sd = _sd()
419
+ self.block = block
420
+ self.stream = sd.InputStream(device=device, samplerate=rate, channels=2, dtype="float32", blocksize=block)
421
+
422
+ def __iter__(self):
423
+ self.stream.start()
424
+ while True:
425
+ data, _ = self.stream.read(self.block)
426
+ yield np.ascontiguousarray(data, dtype=np.float32)
427
+
428
+ def close(self) -> None:
429
+ try:
430
+ self.stream.abort()
431
+ self.stream.close()
432
+ except Exception: # noqa: BLE001
433
+ pass
434
+
435
+
436
+ class SdSink:
437
+ """Playback through PortAudio. Same write(bytes) contract as PacatSink."""
438
+
439
+ def __init__(self, device, rate: int, block: int):
440
+ sd = _sd()
441
+ try:
442
+ self.stream = sd.OutputStream(device=device, samplerate=rate, channels=2, dtype="float32",
443
+ blocksize=block, latency="low")
444
+ self.stream.start()
445
+ except Exception as exc: # noqa: BLE001
446
+ raise AudioError(f"cannot open output device: {exc}")
447
+
448
+ def write(self, data: bytes) -> None:
449
+ try:
450
+ self.stream.write(np.frombuffer(data, dtype="<f4").reshape(-1, 2))
451
+ except Exception as exc: # noqa: BLE001
452
+ raise AudioError(f"playback stream closed: {exc}")
453
+
454
+ def close(self) -> None:
455
+ try:
456
+ self.stream.stop()
457
+ self.stream.close()
458
+ except Exception: # noqa: BLE001
459
+ pass
460
+
461
+
462
+ class MacRoute:
463
+ """Optionally points the macOS output at the loopback device (needs `brew install switchaudio-osx`)."""
464
+
465
+ def __init__(self, device_name: str):
466
+ self.name, self.prev = device_name, None
467
+ self.tool = shutil.which("SwitchAudioSource")
468
+
469
+ def open(self) -> bool:
470
+ if not self.tool:
471
+ return False
472
+ try:
473
+ self.prev = subprocess.run([self.tool, "-c", "-t", "output"], capture_output=True, text=True, timeout=5).stdout.strip()
474
+ subprocess.run([self.tool, "-s", self.name, "-t", "output"], capture_output=True, timeout=5)
475
+ return True
476
+ except (OSError, subprocess.TimeoutExpired):
477
+ return False
478
+
479
+ def close(self) -> None:
480
+ if self.tool and self.prev and self.prev != self.name:
481
+ try:
482
+ subprocess.run([self.tool, "-s", self.prev, "-t", "output"], capture_output=True, timeout=5)
483
+ except (OSError, subprocess.TimeoutExpired):
484
+ pass
485
+
486
+
487
+ def mac_help() -> str:
488
+ return ("macOS has no system-wide audio tap, so DanEQ needs a free virtual loopback device:\n"
489
+ " brew install --cask blackhole-2ch (then log out/in or reboot if macOS asks)\n"
490
+ " brew install switchaudio-osx (optional: lets DanEQ switch outputs for you)\n"
491
+ "Then run `daneq`. Without switchaudio-osx, set System Settings > Sound > Output to\n"
492
+ "'BlackHole 2ch' yourself before starting, and back to your speakers afterwards.\n"
493
+ "You can still use files and test signals without any of this: daneq --demo pink")
daneq/braille.py ADDED
@@ -0,0 +1,134 @@
1
+ """Braille drawing helpers (2x4 dots per terminal cell), numpy backed."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+
6
+ # Weight of the dot at [sub_y][sub_x] inside a braille cell.
7
+ WEIGHT = np.array([[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]], dtype=np.uint32)
8
+
9
+ # Fill patterns, bottom-up, for the left and right dot column of a cell.
10
+ LEFT = np.array([0x00, 0x40, 0x44, 0x46, 0x47], dtype=np.uint32)
11
+ RIGHT = np.array([0x00, 0x80, 0xA0, 0xB0, 0xB8], dtype=np.uint32)
12
+ LCAP = np.array([0x40, 0x04, 0x02, 0x01], dtype=np.uint32)
13
+ RCAP = np.array([0x80, 0x20, 0x10, 0x08], dtype=np.uint32)
14
+
15
+ # Density ramp using braille dots only: SHADE[n] has n dots lit (0..8).
16
+ _ORDER = (0x40, 0x80, 0x04, 0x20, 0x02, 0x10, 0x01, 0x08)
17
+ SHADE = [chr(0x2800 | sum(_ORDER[:n])) for n in range(9)]
18
+
19
+
20
+ def to_str(bits: np.ndarray) -> str:
21
+ return (np.asarray(bits, dtype=np.uint32) + 0x2800).astype("<u4").tobytes().decode("utf-32-le")
22
+
23
+
24
+ def bar_rows(levels: np.ndarray, caps: np.ndarray, h: int):
25
+ """Filled bars as braille rows (top row first).
26
+
27
+ levels: int dot heights (0..4h), two entries per cell column.
28
+ caps: int dot index of a peak-hold dot per entry, or -1 for none.
29
+ """
30
+ left, right = levels[0::2], levels[1::2]
31
+ cl, cr = caps[0::2], caps[1::2]
32
+ rows = []
33
+ for r in range(h - 1, -1, -1):
34
+ base = 4 * r
35
+ bits = LEFT[np.clip(left - base, 0, 4)] | RIGHT[np.clip(right - base, 0, 4)]
36
+ for c, table in ((cl, LCAP), (cr, RCAP)):
37
+ rel = c - base
38
+ m = (rel >= 0) & (rel < 4)
39
+ bits = bits | np.where(m, table[np.clip(rel, 0, 3)], 0).astype(np.uint32)
40
+ rows.append(to_str(bits))
41
+ return rows
42
+
43
+
44
+ class Canvas:
45
+ """Boolean dot grid. (0, 0) is the top-left dot."""
46
+
47
+ def __init__(self, w: int, h: int):
48
+ self.w, self.h = w, h
49
+ self.a = np.zeros((h * 4, w * 2), dtype=bool)
50
+
51
+ def clear(self):
52
+ self.a[:] = False
53
+
54
+ def set(self, x, y):
55
+ if 0 <= x < self.w * 2 and 0 <= y < self.h * 4:
56
+ self.a[int(y), int(x)] = True
57
+
58
+ def points(self, xs, ys):
59
+ xs = np.rint(np.asarray(xs)).astype(int)
60
+ ys = np.rint(np.asarray(ys)).astype(int)
61
+ m = (xs >= 0) & (xs < self.w * 2) & (ys >= 0) & (ys < self.h * 4)
62
+ self.a[ys[m], xs[m]] = True
63
+
64
+ def line(self, x0, y0, x1, y1):
65
+ n = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
66
+ self.points(np.linspace(x0, x1, n), np.linspace(y0, y1, n))
67
+
68
+ def vspan(self, x, y0, y1):
69
+ x = int(x)
70
+ if not 0 <= x < self.w * 2:
71
+ return
72
+ y0, y1 = int(round(min(y0, y1))), int(round(max(y0, y1)))
73
+ self.a[max(y0, 0):min(y1, self.h * 4 - 1) + 1, x] = True
74
+
75
+ def hspan(self, y, x0, x1):
76
+ y = int(round(y))
77
+ if not 0 <= y < self.h * 4:
78
+ return
79
+ x0, x1 = int(round(min(x0, x1))), int(round(max(x0, x1)))
80
+ self.a[y, max(x0, 0):min(x1, self.w * 2 - 1) + 1] = True
81
+
82
+ def circle(self, cx, cy, r, a0=0.0, a1=2 * np.pi):
83
+ n = max(int(r * abs(a1 - a0) * 1.6), 12)
84
+ ang = np.linspace(a0, a1, n)
85
+ self.points(cx + r * np.cos(ang), cy + r * np.sin(ang))
86
+
87
+ def disc(self, cx, cy, r):
88
+ for yy in range(int(cy - r), int(cy + r) + 1):
89
+ dx = max(r * r - (yy - cy) ** 2, 0.0) ** 0.5
90
+ self.hspan(yy, cx - dx, cx + dx)
91
+
92
+ def bits(self) -> np.ndarray:
93
+ a = self.a.reshape(self.h, 4, self.w, 2).astype(np.uint32)
94
+ return (a * WEIGHT[None, :, None, :]).sum(axis=(1, 3))
95
+
96
+ def bands(self, k: int):
97
+ """Split into k horizontal band canvases (top to bottom) for gradients."""
98
+ out, rows = [], self.h
99
+ for i in range(k):
100
+ c = Canvas(self.w, self.h)
101
+ r0, r1 = rows * i // k * 4, rows * (i + 1) // k * 4
102
+ c.a[r0:r1] = self.a[r0:r1]
103
+ out.append(c)
104
+ return out
105
+
106
+
107
+ def blit(put, iy, ix, layers):
108
+ """Draw canvases bottom to top. layers: [(Canvas, attr)]. Top-most dot colors a cell."""
109
+ if not layers:
110
+ return
111
+ h, w = layers[0][0].h, layers[0][0].w
112
+ union = np.zeros((h, w), dtype=np.uint32)
113
+ top = np.zeros((h, w), dtype=np.int32)
114
+ for i, (cv, _) in enumerate(layers):
115
+ b = cv.bits()
116
+ union |= b
117
+ top[b > 0] = i + 1
118
+ attrs = [None] + [a for _, a in layers]
119
+ for r in range(h):
120
+ tl = top[r].tolist()
121
+ if not any(tl):
122
+ continue
123
+ s = to_str(union[r])
124
+ c = 0
125
+ while c < w:
126
+ k = tl[c]
127
+ if k == 0:
128
+ c += 1
129
+ continue
130
+ e = c + 1
131
+ while e < w and tl[e] == k:
132
+ e += 1
133
+ put(iy + r, ix + c, s[c:e], attrs[k])
134
+ c = e