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.
- spl_dashboard/__init__.py +3 -0
- spl_dashboard/addresses.py +56 -0
- spl_dashboard/calibration.py +131 -0
- spl_dashboard/capture.py +269 -0
- spl_dashboard/cli.py +108 -0
- spl_dashboard/dsp.py +178 -0
- spl_dashboard/main.py +401 -0
- spl_dashboard/models.py +153 -0
- spl_dashboard/runtime.py +483 -0
- spl_dashboard/spectrum.py +235 -0
- spl_dashboard/static/assets/index-4D3s-Wsb.js +15 -0
- spl_dashboard/static/assets/index-Cc6MPaAZ.css +1 -0
- spl_dashboard/static/icon.svg +1 -0
- spl_dashboard/static/index.html +24 -0
- spl_dashboard/static/manifest.webmanifest +1 -0
- spl_dashboard/storage.py +206 -0
- spl_dashboard-0.5.0.dist-info/METADATA +77 -0
- spl_dashboard-0.5.0.dist-info/RECORD +21 -0
- spl_dashboard-0.5.0.dist-info/WHEEL +4 -0
- spl_dashboard-0.5.0.dist-info/entry_points.txt +2 -0
- spl_dashboard-0.5.0.dist-info/licenses/LICENSE +674 -0
|
@@ -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"
|
spl_dashboard/capture.py
ADDED
|
@@ -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()
|