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 +3 -0
- daneq/__main__.py +3 -0
- daneq/audio.py +493 -0
- daneq/braille.py +134 -0
- daneq/cli.py +309 -0
- daneq/clips.py +85 -0
- daneq/config.py +29 -0
- daneq/dsp.py +231 -0
- daneq/effects.py +493 -0
- daneq/engine.py +92 -0
- daneq/nerd.py +466 -0
- daneq/sounds.py +542 -0
- daneq/synth.py +103 -0
- daneq/themes.py +55 -0
- daneq/ui.py +920 -0
- daneq/visuals.py +455 -0
- daneq-0.4.2.dist-info/METADATA +654 -0
- daneq-0.4.2.dist-info/RECORD +22 -0
- daneq-0.4.2.dist-info/WHEEL +5 -0
- daneq-0.4.2.dist-info/entry_points.txt +2 -0
- daneq-0.4.2.dist-info/licenses/LICENSE +674 -0
- daneq-0.4.2.dist-info/top_level.txt +1 -0
daneq/__init__.py
ADDED
daneq/__main__.py
ADDED
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
|