spl-dashboard 0.5.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.
@@ -0,0 +1,3 @@
1
+ """SPL Dashboard telemetry service."""
2
+
3
+ __version__ = "0.4.4"
@@ -0,0 +1,56 @@
1
+ """Discover the machine's LAN addresses so operators can reach the dashboard.
2
+
3
+ Browsers on other devices (an iPad, a phone, a laptop) connect to one of these
4
+ addresses over the local network. Nothing is installed on those devices; they
5
+ only view the dashboard.
6
+ """
7
+
8
+ import socket
9
+
10
+ IPAD_NOTE = (
11
+ "Other devices (iPad, phone, laptop) only view the dashboard over your "
12
+ "network; nothing is installed on them."
13
+ )
14
+
15
+
16
+ def local_ipv4_addresses() -> list[str]:
17
+ """Return non-loopback, non-link-local IPv4 addresses for this machine."""
18
+ found: set[str] = set()
19
+ # Primary outbound interface. connect() on a UDP socket sends no packets;
20
+ # it only selects the route the OS would use, revealing the local address.
21
+ try:
22
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
23
+ probe.connect(("192.0.2.1", 80)) # TEST-NET-1: reserved, never routed.
24
+ found.add(str(probe.getsockname()[0]))
25
+ except OSError:
26
+ pass
27
+ # Every address the hostname resolves to (covers additional interfaces).
28
+ try:
29
+ for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
30
+ found.add(str(info[4][0]))
31
+ except OSError:
32
+ pass
33
+ return sorted(
34
+ address
35
+ for address in found
36
+ if not address.startswith("127.") and not address.startswith("169.254.")
37
+ )
38
+
39
+
40
+ def address_report(port: int) -> dict:
41
+ """Build the local and LAN URLs for the admin console and the /display strip."""
42
+ lan = [
43
+ {
44
+ "host": address,
45
+ "admin": f"http://{address}:{port}/",
46
+ "display": f"http://{address}:{port}/display",
47
+ }
48
+ for address in local_ipv4_addresses()
49
+ ]
50
+ return {
51
+ "port": port,
52
+ "localAdmin": f"http://localhost:{port}/",
53
+ "localDisplay": f"http://localhost:{port}/display",
54
+ "lan": lan,
55
+ "note": IPAD_NOTE,
56
+ }
@@ -0,0 +1,131 @@
1
+ """Calibration file parsing; vendor sensitivity is retained, never guessed."""
2
+
3
+ import hashlib
4
+ import re
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ from numpy.typing import NDArray
9
+ from scipy import signal
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Calibration:
14
+ digest: str
15
+ serial: str | None
16
+ sensitivity: float | None
17
+ frequencies: NDArray[np.float64]
18
+ response: NDArray[np.float64]
19
+ analog_gain: float | None = None
20
+
21
+ def fir(self, sample_rate: int) -> NDArray[np.float64]:
22
+ # Vendor data is measured response, so correction has the opposite sign.
23
+ grid = np.linspace(0, sample_rate / 2, 8193)
24
+ response = np.interp(
25
+ np.log10(np.maximum(grid, 1)), np.log10(self.frequencies), self.response
26
+ )
27
+ return signal.firwin2(2049, grid, 10 ** (-response / 20), fs=sample_rate)
28
+
29
+
30
+ def parse_calibration(text: str) -> Calibration | None:
31
+ if not text.strip():
32
+ return None
33
+ if len(text) > 200000:
34
+ raise ValueError("Calibration file exceeds 200 kB")
35
+ sensitivity = re.search(r"Sens\s+Factor\s*=\s*([-+\d.eE]+)\s*dB", text, re.I)
36
+ if re.search(r"Sens\s+Factor", text, re.I) and not sensitivity:
37
+ raise ValueError("Invalid sensitivity factor header")
38
+ analog_gain = re.search(r"AGain\s*=\s*([-+\d.eE]+)\s*dB", text, re.I)
39
+ if re.search(r"AGain", text, re.I) and not analog_gain:
40
+ raise ValueError("Invalid analog gain header")
41
+ file_gain = float(analog_gain[1]) if analog_gain else None
42
+ if file_gain is not None and (not np.isfinite(file_gain) or not 0 <= file_gain <= 60):
43
+ raise ValueError("Invalid analog gain")
44
+ serial = re.search(r"SERNO\s*:\s*([\w-]+)", text, re.I)
45
+ points: list[tuple[float, float]] = []
46
+ for number, line in enumerate(text.splitlines(), 1):
47
+ line = line.strip()
48
+ if not line or line[0] not in "+-.0123456789":
49
+ continue
50
+ columns = line.replace(",", " ").split()
51
+ try:
52
+ frequency, gain = float(columns[0]), float(columns[1])
53
+ except (ValueError, IndexError) as exc:
54
+ raise ValueError(f"Invalid calibration data on line {number}") from exc
55
+ if not np.isfinite([frequency, gain]).all() or not 0 < frequency <= 96000:
56
+ raise ValueError(f"Invalid frequency or gain on line {number}")
57
+ if abs(gain) > 30:
58
+ raise ValueError("Calibration response exceeds ±30 dB; inspect the file")
59
+ if points and frequency <= points[-1][0]:
60
+ raise ValueError("Calibration frequencies must be strictly increasing")
61
+ points.append((frequency, gain))
62
+ if len(points) < 2:
63
+ raise ValueError("Calibration requires at least two frequency/response pairs")
64
+ sens = float(sensitivity[1]) if sensitivity else None
65
+ if sens is not None and (not np.isfinite(sens) or not -100 <= sens <= 40):
66
+ raise ValueError("Invalid sensitivity factor")
67
+ values = np.asarray(points, dtype=np.float64)
68
+ return Calibration(
69
+ hashlib.sha256(text.encode()).hexdigest(),
70
+ serial[1] if serial else None,
71
+ sens,
72
+ values[:, 0],
73
+ values[:, 1],
74
+ file_gain,
75
+ )
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class InputCalibration:
80
+ """Evidence from the selected device, not from a browser-supplied offset."""
81
+
82
+ supported_umik: bool = False
83
+ direct_pcm: bool = False
84
+ analog_gain_db: float | None = None
85
+ digital_gain_db: float | None = None
86
+ usb_serial: str | None = None
87
+ binding: str = "name"
88
+ reason: str = "Waiting for the selected microphone"
89
+
90
+
91
+ def serial_digits(serial: str | None) -> str:
92
+ return re.sub(r"[^0-9]", "", serial or "")
93
+
94
+
95
+ def usable_serial(serial: str | None) -> bool:
96
+ digits = serial_digits(serial)
97
+ return bool(digits and set(digits) != {"0"})
98
+
99
+
100
+ def umik_file_offset(
101
+ calibration: Calibration | None, device: InputCalibration, confirmed_serial: str = ""
102
+ ) -> tuple[float | None, str]:
103
+ """UMIK-1 direct PCM convention; see docs/umik-sensitivity.md.
104
+
105
+ Full-scale sample magnitude is 1; full-scale sine RMS is -3.0103 dBFS.
106
+ The internal analog gain is already included in the supplied sensitivity.
107
+ Only a difference from the file's gain is compensated, never the gain twice.
108
+ """
109
+ if calibration is None or calibration.sensitivity is None:
110
+ return None, "Load the microphone's calibration file with its Sens Factor"
111
+ if not device.supported_umik or not device.direct_pcm:
112
+ return None, device.reason
113
+ if device.analog_gain_db is None or device.digital_gain_db is None:
114
+ return None, "Input gain could not be verified; use an explicit acoustic reference"
115
+ serial = serial_digits(calibration.serial)
116
+ if not serial:
117
+ return None, "The calibration file has no microphone serial"
118
+ if usable_serial(device.usb_serial):
119
+ if serial_digits(device.usb_serial) != serial:
120
+ return None, "Calibration file serial does not match the connected microphone"
121
+ elif serial_digits(confirmed_serial) != serial:
122
+ return None, "Confirm that the file serial matches the label on this microphone"
123
+ reference_gain = calibration.analog_gain if calibration.analog_gain is not None else 18.0
124
+ offset = (
125
+ 124.0
126
+ - calibration.sensitivity
127
+ + reference_gain
128
+ - device.analog_gain_db
129
+ - device.digital_gain_db
130
+ )
131
+ return offset, "Calibrated to the supplied UMIK-1 sensitivity file"
@@ -0,0 +1,269 @@
1
+ """Bounded local capture. Audio never leaves this module's process."""
2
+
3
+ import re
4
+ import subprocess
5
+ import threading
6
+ import time
7
+ import wave
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from queue import Empty, Full, Queue
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ from numpy.typing import NDArray
15
+
16
+ from .calibration import InputCalibration, usable_serial
17
+ from .dsp import SAMPLE_RATE
18
+
19
+ CAPTURE_BLOCK = 4800
20
+
21
+ _portaudio_lock = threading.RLock()
22
+ _active_streams = 0
23
+
24
+
25
+ @dataclass
26
+ class AudioBlock:
27
+ samples: NDArray[np.float64]
28
+ captured: float
29
+ sequence: int
30
+ overflow: bool = False
31
+
32
+
33
+ def devices() -> list[dict[str, Any]]:
34
+ """Refresh PortAudio only when no input stream is open; never interrupt capture."""
35
+ import sounddevice as sd
36
+
37
+ with _portaudio_lock:
38
+ if not _active_streams:
39
+ sd._terminate()
40
+ sd._initialize()
41
+ hosts = sd.query_hostapis()
42
+ result = []
43
+ for index, device in enumerate(sd.query_devices()):
44
+ if device["max_input_channels"] < 1:
45
+ continue
46
+ name = device["name"]
47
+ host = hosts[device["hostapi"]]["name"]
48
+ stable_name = re.sub(r"\s*\(hw:\d+,\d+\)", "", name)
49
+ identity = f"portaudio:{host}:{stable_name}"
50
+ binding, serial, vendor, product, card_index = "name", None, None, None, None
51
+ match = re.search(r"hw:(\d+),(\d+)", name)
52
+ if host == "ALSA" and match:
53
+ card_index = int(match[1])
54
+ card = Path(f"/sys/class/sound/card{card_index}/device").resolve()
55
+ for parent in [card, *list(card.parents)[:4]]:
56
+ if (parent / "idVendor").is_file() and (parent / "idProduct").is_file():
57
+ vendor = (parent / "idVendor").read_text().strip()
58
+ product = (parent / "idProduct").read_text().strip()
59
+ serial_path = parent / "serial"
60
+ serial = serial_path.read_text().strip() if serial_path.is_file() else None
61
+ if usable_serial(serial):
62
+ identity = f"usb:{vendor}:{product}:{serial}:pcm{match[2]}"
63
+ binding = "serial"
64
+ else:
65
+ # A placeholder serial identifies a model, not a physical microphone.
66
+ controller = next(
67
+ (
68
+ p.name
69
+ for p in parent.parents
70
+ if re.fullmatch(
71
+ r"[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-7]", p.name
72
+ )
73
+ ),
74
+ "unknown",
75
+ )
76
+ port = parent.name.partition("-")[2]
77
+ identity = (
78
+ f"usb-port:{vendor}:{product}:{controller}/{port}:pcm{match[2]}"
79
+ )
80
+ binding = "usb-port"
81
+ break
82
+ result.append(
83
+ {
84
+ "id": identity,
85
+ "index": index,
86
+ "name": name,
87
+ "channels": device["max_input_channels"],
88
+ "defaultSampleRate": device["default_samplerate"],
89
+ "serialBound": binding == "serial",
90
+ "binding": binding,
91
+ "usbSerial": serial,
92
+ "usbVendor": vendor,
93
+ "usbProduct": product,
94
+ "alsaCard": card_index,
95
+ }
96
+ )
97
+ return result
98
+
99
+
100
+ def inspect_calibration_input(selected: dict[str, Any]) -> InputCalibration:
101
+ """Recognize the legacy UMIK-1 direct ALSA path and verify its mixer controls."""
102
+ common = {"usb_serial": selected.get("usbSerial"), "binding": selected.get("binding", "name")}
103
+ gain_match = re.search(r"Umik-1\s+Gain:\s*(\d+)dB", selected["name"], re.I)
104
+ if (selected.get("usbVendor"), selected.get("usbProduct")) != (
105
+ "2752",
106
+ "0007",
107
+ ) or not gain_match:
108
+ return InputCalibration(
109
+ **common, reason="Automatic calibration needs a supported direct UMIK-1 input"
110
+ )
111
+ gain = float(gain_match[1])
112
+ if gain not in (0, 6, 12, 18, 24, 30, 36) or selected.get("alsaCard") is None:
113
+ return InputCalibration(**common, reason="UMIK analog gain could not be identified")
114
+ try:
115
+ mixer = subprocess.run(
116
+ ["amixer", "-c", str(selected["alsaCard"]), "contents"],
117
+ capture_output=True,
118
+ text=True,
119
+ timeout=5,
120
+ check=True,
121
+ ).stdout
122
+ except (OSError, subprocess.SubprocessError):
123
+ return InputCalibration(**common, reason="Cannot inspect ALSA gain; install alsa-utils")
124
+ # This device generation exposes only a mute switch. Reject volume/unknown
125
+ # controls rather than guessing their gain mapping or silently applying it.
126
+ sections = [
127
+ part
128
+ for part in mixer.split("numid=")[1:]
129
+ if not ("iface=PCM" in part and "name='Capture Channel Map'" in part)
130
+ ]
131
+ if not sections or any(
132
+ "name='Mic Capture Switch'" not in part or "type=BOOLEAN" not in part for part in sections
133
+ ):
134
+ return InputCalibration(
135
+ **common, reason="Unexpected input gain controls; use an acoustic reference"
136
+ )
137
+ if any(re.search(r": values=(?:off|0)(?:\s|,|$)", part) for part in sections):
138
+ return InputCalibration(**common, reason="MIC MUTED — enable the ALSA capture switch")
139
+ return InputCalibration(
140
+ **common,
141
+ supported_umik=True,
142
+ direct_pcm=True,
143
+ analog_gain_db=gain,
144
+ digital_gain_db=0.0,
145
+ reason="Direct ALSA PCM; no digital gain control",
146
+ )
147
+
148
+
149
+ class DeviceInput:
150
+ def __init__(self, identity: str, channel: int):
151
+ import sounddevice as sd
152
+
153
+ global _active_streams
154
+ self.closed = False
155
+ with _portaudio_lock:
156
+ matches = [d for d in devices() if d["id"] == identity]
157
+ if len(matches) != 1:
158
+ raise ValueError("Selected device is missing or ambiguous; select a unique input")
159
+ selected = matches[0]
160
+ if channel >= selected["channels"]:
161
+ raise ValueError("Selected channel does not exist")
162
+ self.calibration_info = inspect_calibration_input(selected)
163
+ self.queue: Queue[AudioBlock] = Queue(maxsize=8)
164
+ self.dropped = 0
165
+ self.sequence = 0
166
+ self.stream = sd.InputStream(
167
+ device=selected["index"],
168
+ samplerate=SAMPLE_RATE,
169
+ blocksize=CAPTURE_BLOCK,
170
+ channels=channel + 1,
171
+ dtype="float32",
172
+ callback=self.callback(channel),
173
+ )
174
+ try:
175
+ self.stream.start()
176
+ except Exception:
177
+ self.stream.close()
178
+ raise
179
+ _active_streams += 1
180
+
181
+ def callback(self, channel: int) -> Any:
182
+ def receive(data: Any, frames: int, timing: Any, status: Any) -> None:
183
+ self.sequence += 1
184
+ block = AudioBlock(
185
+ np.asarray(data[:, channel], dtype=np.float64).copy(),
186
+ time.monotonic(),
187
+ self.sequence,
188
+ bool(status),
189
+ )
190
+ try:
191
+ self.queue.put_nowait(block)
192
+ except Full:
193
+ self.dropped += 1
194
+
195
+ return receive
196
+
197
+ def read(self) -> AudioBlock | None:
198
+ try:
199
+ return self.queue.get(timeout=0.25)
200
+ except Empty:
201
+ return None
202
+
203
+ def close(self) -> None:
204
+ global _active_streams
205
+ with _portaudio_lock:
206
+ if self.closed:
207
+ return
208
+ try:
209
+ self.stream.stop()
210
+ finally:
211
+ try:
212
+ self.stream.close()
213
+ finally:
214
+ _active_streams -= 1
215
+ self.closed = True
216
+
217
+
218
+ class WavInput:
219
+ def __init__(self, path: Path, channel: int):
220
+ self.file = wave.open(str(path), "rb")
221
+ self.channel = channel
222
+ self.sequence = 0
223
+ if (
224
+ self.file.getframerate() != SAMPLE_RATE
225
+ or self.file.getsampwidth() not in (2, 3, 4)
226
+ or self.file.getcomptype() != "NONE"
227
+ or channel >= self.file.getnchannels()
228
+ ):
229
+ self.file.close()
230
+ raise ValueError("WAV must be 48 kHz PCM 16/24/32-bit with the selected channel")
231
+
232
+ def read(self) -> AudioBlock | None:
233
+ raw = self.file.readframes(CAPTURE_BLOCK)
234
+ width = self.file.getsampwidth()
235
+ channels = self.file.getnchannels()
236
+ if len(raw) != CAPTURE_BLOCK * width * channels:
237
+ return None # No zero padding and no silent loop across a discontinuity.
238
+ if width == 3:
239
+ triples = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3).astype(np.int32)
240
+ integer = triples[:, 0] | (triples[:, 1] << 8) | (triples[:, 2] << 16)
241
+ integer = (integer ^ 0x800000) - 0x800000
242
+ samples = integer.astype(np.float64) / 8388608
243
+ else:
244
+ samples = np.frombuffer(raw, dtype=f"<i{width}").astype(np.float64)
245
+ samples /= 2 ** (width * 8 - 1)
246
+ self.sequence += 1
247
+ return AudioBlock(
248
+ samples.reshape(-1, channels)[:, self.channel], time.monotonic(), self.sequence
249
+ )
250
+
251
+ def close(self) -> None:
252
+ self.file.close()
253
+
254
+
255
+ class DemoInput:
256
+ def __init__(self) -> None:
257
+ self.sequence = 0
258
+ self.rng = np.random.default_rng(42)
259
+
260
+ def read(self) -> AudioBlock:
261
+ t = (np.arange(CAPTURE_BLOCK) + self.sequence * CAPTURE_BLOCK) / SAMPLE_RATE
262
+ amplitude = 0.025 * 10 ** (3 * np.sin(t / 8) / 20)
263
+ samples = amplitude * (np.sin(2 * np.pi * 1000 * t) + 0.6 * np.sin(2 * np.pi * 80 * t))
264
+ samples += self.rng.normal(0, 0.002, CAPTURE_BLOCK)
265
+ self.sequence += 1
266
+ return AudioBlock(samples, time.monotonic(), self.sequence)
267
+
268
+ def close(self) -> None:
269
+ pass
spl_dashboard/cli.py ADDED
@@ -0,0 +1,108 @@
1
+ """Console entry point: ``spl-dashboard``.
2
+
3
+ Starts the LAN service with a single Uvicorn worker and prints the addresses to
4
+ open on this machine and on other devices on the same network.
5
+ """
6
+
7
+ import argparse
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from platformdirs import user_data_dir
12
+
13
+ from .addresses import IPAD_NOTE, local_ipv4_addresses
14
+
15
+ DEFAULT_PORT = 8000
16
+ # A LAN monitor is meant to be reachable across the local network by default.
17
+ DEFAULT_HOST = "0.0.0.0"
18
+
19
+
20
+ def default_data_dir() -> Path:
21
+ """Per-user data directory (platform-appropriate), matching the service default."""
22
+ return Path(user_data_dir("spl-dashboard", appauthor=False))
23
+
24
+
25
+ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
26
+ parser = argparse.ArgumentParser(
27
+ prog="spl-dashboard",
28
+ description=(
29
+ "Run the SPL Dashboard sound monitor on this computer and serve it to "
30
+ "browsers on your local network."
31
+ ),
32
+ )
33
+ parser.add_argument(
34
+ "--host",
35
+ default=DEFAULT_HOST,
36
+ help=f"Address to bind (default {DEFAULT_HOST}, i.e. all network interfaces).",
37
+ )
38
+ parser.add_argument(
39
+ "--port",
40
+ type=int,
41
+ default=DEFAULT_PORT,
42
+ help=f"Port to serve on (default {DEFAULT_PORT}).",
43
+ )
44
+ parser.add_argument(
45
+ "--data-dir",
46
+ default=None,
47
+ help=(
48
+ "Where events, configuration and calibration are stored "
49
+ f"(default: {default_data_dir()})."
50
+ ),
51
+ )
52
+ parser.add_argument("--version", action="store_true", help="Print the version and exit.")
53
+ return parser.parse_args(argv)
54
+
55
+
56
+ def _print_banner(host: str, port: int, data_dir: Path) -> None:
57
+ lines = ["", " SPL Dashboard is starting.", ""]
58
+ lines.append(" On this computer:")
59
+ lines.append(f" Admin console: http://localhost:{port}/")
60
+ lines.append(f" Strip display: http://localhost:{port}/display")
61
+ addresses = local_ipv4_addresses()
62
+ if host not in ("0.0.0.0", "::") and host not in ("localhost", "127.0.0.1"):
63
+ # An explicit non-wildcard bind is the only reachable LAN address.
64
+ addresses = [host]
65
+ if addresses:
66
+ lines.append("")
67
+ lines.append(" From an iPad, phone or laptop on the same Wi-Fi (open /display):")
68
+ for address in addresses:
69
+ lines.append(f" http://{address}:{port}/display")
70
+ else:
71
+ lines.append("")
72
+ lines.append(" No LAN address detected yet; connect this computer to your network.")
73
+ lines.append("")
74
+ lines.append(f" {IPAD_NOTE}")
75
+ lines.append(f" Data directory: {data_dir}")
76
+ lines.append(" Press Ctrl+C to stop.")
77
+ lines.append("")
78
+ print("\n".join(lines), flush=True)
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> None:
82
+ args = _parse_args(argv)
83
+ if args.version:
84
+ from importlib.metadata import version
85
+
86
+ print(version("spl-dashboard"))
87
+ return
88
+
89
+ data_dir = Path(args.data_dir).expanduser() if args.data_dir else default_data_dir()
90
+ data_dir.mkdir(parents=True, exist_ok=True)
91
+ # create_app reads SPL_DATA_DIR when Uvicorn imports the app in this process.
92
+ os.environ["SPL_DATA_DIR"] = str(data_dir)
93
+
94
+ _print_banner(args.host, args.port, data_dir)
95
+
96
+ import uvicorn
97
+
98
+ uvicorn.run(
99
+ "spl_dashboard.main:app",
100
+ host=args.host,
101
+ port=args.port,
102
+ workers=1,
103
+ log_level="info",
104
+ )
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()