slm_open_api_package 0.2.10__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.
- slm_api/CPB_example.py +74 -0
- slm_api/__init__.py +5 -0
- slm_api/enums/fast_log_intervals_enum.py +16 -0
- slm_api/enums/sequence_id_enum.py +352 -0
- slm_api/example_multi.py +78 -0
- slm_api/fast_log_example.py +172 -0
- slm_api/flac_example.py +164 -0
- slm_api/helpers/FigureHandler.py +65 -0
- slm_api/helpers/Leq.py +87 -0
- slm_api/helpers/__init__.py +5 -0
- slm_api/helpers/buffer.py +27 -0
- slm_api/helpers/data_handler.py +94 -0
- slm_api/helpers/fft.py +38 -0
- slm_api/helpers/flac_stream_2_samples.py +64 -0
- slm_api/helpers/https_requests.py +79 -0
- slm_api/helpers/measurment_handler.py +20 -0
- slm_api/helpers/sequence_handler.py +43 -0
- slm_api/helpers/stream_handler.py +105 -0
- slm_api/helpers/stream_handlers.py +434 -0
- slm_api/helpers/tmpFileStateMachine.py +93 -0
- slm_api/helpers/websocket_handler.py +12 -0
- slm_api/helpers/webxi_helper_functions.py +79 -0
- slm_api/helpers/webxi_root.pem +24 -0
- slm_api/mp3_example.py +165 -0
- slm_api/multi_device_flac.py +105 -0
- slm_api/multi_instrument_example.py +74 -0
- slm_api/screen_record_example.py +64 -0
- slm_api/webxi/README.md +25 -0
- slm_api/webxi/__init__.py +5 -0
- slm_api/webxi/webxi-header.ksy +32 -0
- slm_api/webxi/webxi-stream.ksy +342 -0
- slm_api/webxi/webxi_header.py +42 -0
- slm_api/webxi/webxi_stream.py +450 -0
- slm_open_api_package-0.2.10.dist-info/METADATA +206 -0
- slm_open_api_package-0.2.10.dist-info/RECORD +37 -0
- slm_open_api_package-0.2.10.dist-info/WHEEL +4 -0
- slm_open_api_package-0.2.10.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from . import https_requests
|
|
2
|
+
|
|
3
|
+
def find_sequence(sequenceId, sequences):
|
|
4
|
+
"""Iterations through the different sequences on the device to find the wanted one"""
|
|
5
|
+
res = None
|
|
6
|
+
if isinstance(sequences, dict):
|
|
7
|
+
if str(sequenceId) in sequences:
|
|
8
|
+
return sequences[str(sequenceId)]
|
|
9
|
+
else:
|
|
10
|
+
for _, subseq in sequences.items():
|
|
11
|
+
if isinstance(subseq, dict):
|
|
12
|
+
res = find_sequence(sequenceId, subseq)
|
|
13
|
+
if res is not None:
|
|
14
|
+
return res
|
|
15
|
+
|
|
16
|
+
def find_sequence_by_name(sequenceName, sequences):
|
|
17
|
+
if isinstance(sequences, dict):
|
|
18
|
+
if "Name" in sequences:
|
|
19
|
+
return True if sequences["Name"] == sequenceName else False
|
|
20
|
+
else:
|
|
21
|
+
for count, subtree in sequences.items():
|
|
22
|
+
test = find_sequence_by_name(sequenceName, subtree)
|
|
23
|
+
if isinstance(test, bool) and test:
|
|
24
|
+
return int(count)
|
|
25
|
+
elif isinstance(test, int) and test:
|
|
26
|
+
return test
|
|
27
|
+
|
|
28
|
+
def getSequenceID(host, sequenceName):
|
|
29
|
+
sequences = https_requests.get(host, "/webxi/sequences?recursive").json()
|
|
30
|
+
return find_sequence_by_name(sequenceName, sequences)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_sequence(hostID,ID):
|
|
34
|
+
"""This function checks if the ID exists"""
|
|
35
|
+
response = https_requests.get(hostID, "/webxi/sequences?recursive")
|
|
36
|
+
sequences = response.json()
|
|
37
|
+
sequence = find_sequence(ID, sequences)
|
|
38
|
+
if sequence == None:
|
|
39
|
+
raise Exception("No such sequence: " + str(ID))
|
|
40
|
+
else:
|
|
41
|
+
print(sequence)
|
|
42
|
+
return ID, sequence
|
|
43
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from . import https_requests
|
|
2
|
+
import asyncio
|
|
3
|
+
from . import websocket_handler
|
|
4
|
+
from . import measurment_handler
|
|
5
|
+
|
|
6
|
+
def setup_stream(host, sequenceID, StreamName):
|
|
7
|
+
"""Initilize a stream and start it. Returns the URI for the stream"""
|
|
8
|
+
|
|
9
|
+
# This line checks if only one sequenceID is given and converts it to a list. Otherwise it will keep the list given
|
|
10
|
+
sequenceID = [sequenceID] if isinstance(sequenceID, int) else sequenceID
|
|
11
|
+
|
|
12
|
+
body = {
|
|
13
|
+
"ConnectionType": "WebSocket",
|
|
14
|
+
"Sequences": sequenceID,
|
|
15
|
+
"MessageTypes": ["SequenceData"],
|
|
16
|
+
"Name": StreamName
|
|
17
|
+
}
|
|
18
|
+
response = https_requests.post(host, "/WebXi/Streams", json = body)
|
|
19
|
+
print(response)
|
|
20
|
+
if not (response.status_code == 201):
|
|
21
|
+
print(response.text)
|
|
22
|
+
raise Exception("Cannot start stream, could be bacause of to many streams open on the device")
|
|
23
|
+
https = https_requests.UseHttps.use_https
|
|
24
|
+
websocket_host = host.removeprefix("https://").removeprefix("http://")
|
|
25
|
+
if https:
|
|
26
|
+
return 'wss://' + websocket_host + response.json()["URI"][0]
|
|
27
|
+
else:
|
|
28
|
+
return 'ws://' + websocket_host + response.json()["URI"][0]
|
|
29
|
+
|
|
30
|
+
def get_stream_ID(host, streamName):
|
|
31
|
+
"""This function shows how to find the ID of a specific stream with specific name"""
|
|
32
|
+
streams = https_requests.get(host, "/WebXi/Streams?recursive").json()
|
|
33
|
+
# The keys are the stream IDs and are not renumbered when a stream is deleted
|
|
34
|
+
matches = [int(ID) for ID, subtree in streams.items() if subtree["Name"] == streamName]
|
|
35
|
+
return max(matches) if matches else None
|
|
36
|
+
|
|
37
|
+
def delete_stream(host, streamName):
|
|
38
|
+
"""Look up a stream by name and delete it, cleaning up the device resources used"""
|
|
39
|
+
streamID = get_stream_ID(host, streamName)
|
|
40
|
+
if streamID is not None:
|
|
41
|
+
response = https_requests.delete(host, "/WebXi/Streams/" + str(streamID), timeout=10)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
|
|
44
|
+
def data_type_conv(data_type, value, vector_length):
|
|
45
|
+
"""Convert the byte data retrived from BK2245 to Int16 format\n
|
|
46
|
+
The byte format is in 'little'"""
|
|
47
|
+
if data_type == "Int16":
|
|
48
|
+
if vector_length == None:
|
|
49
|
+
return int.from_bytes(value, "little", signed=True)
|
|
50
|
+
else:
|
|
51
|
+
value_array = []
|
|
52
|
+
for i in range(0, vector_length*2, 2):
|
|
53
|
+
value_array.append(int.from_bytes(value[i:i+2], "little", signed=True))
|
|
54
|
+
# print(len(value_array))
|
|
55
|
+
return value_array
|
|
56
|
+
elif data_type == "BKTimeSpan":
|
|
57
|
+
return int.from_bytes(value[-4:], "little", signed=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class StreamHandler:
|
|
61
|
+
"""Base class managing the asyncio lifecycle of a WebXi websocket stream.
|
|
62
|
+
Subclasses must implement streamInit() (setting self.host and self.uri) and
|
|
63
|
+
msg_func(msg) to handle each incoming message. streamName is used to look up
|
|
64
|
+
and delete the stream on the device when stopped."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, streamName):
|
|
67
|
+
self.streamName = streamName
|
|
68
|
+
self._stop_event = None
|
|
69
|
+
self._stop_requested = False
|
|
70
|
+
|
|
71
|
+
def streamInit(self):
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
def msg_func(self, msg):
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
def startStream(self):
|
|
78
|
+
asyncio.run(self.runStream())
|
|
79
|
+
|
|
80
|
+
async def runStream(self):
|
|
81
|
+
self._stop_event = asyncio.Event()
|
|
82
|
+
self.loop = asyncio.get_running_loop()
|
|
83
|
+
task = self.loop.create_task(websocket_handler.next_async_websocket(self.uri, self.msg_func))
|
|
84
|
+
task.add_done_callback(self._report_task_failure)
|
|
85
|
+
if self._stop_requested:
|
|
86
|
+
self._stop_event.set()
|
|
87
|
+
await self._stop_event.wait()
|
|
88
|
+
task.cancel()
|
|
89
|
+
|
|
90
|
+
def _report_task_failure(self, task):
|
|
91
|
+
if task.cancelled():
|
|
92
|
+
return
|
|
93
|
+
exception = task.exception()
|
|
94
|
+
if exception is not None:
|
|
95
|
+
print(f"[{self.streamName}] Stream task failed: {exception!r}")
|
|
96
|
+
|
|
97
|
+
def request_stop(self):
|
|
98
|
+
self._stop_requested = True
|
|
99
|
+
if self._stop_event is not None and not self.loop.is_closed():
|
|
100
|
+
self.loop.call_soon_threadsafe(self._stop_event.set)
|
|
101
|
+
|
|
102
|
+
def stopStream(self):
|
|
103
|
+
measurment_handler.stop_measurement(self.host)
|
|
104
|
+
self.request_stop()
|
|
105
|
+
delete_stream(self.host, self.streamName) # Cleaning up and deleting the stream used
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""A single, configurable StreamHandler covering common WebXi streaming use
|
|
2
|
+
cases (single Leq sequence, CPB, multiple sequences, FLAC/mp3 audio). The mode
|
|
3
|
+
is picked with boolean flags (cpb, multi, flac, mp3) instead of subclassing."""
|
|
4
|
+
|
|
5
|
+
from . import https_requests
|
|
6
|
+
import time
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from timeit import default_timer as timer
|
|
10
|
+
import numpy as np
|
|
11
|
+
import miniaudio
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
from . import sequence_handler as seq
|
|
15
|
+
from . import stream_handler as stream
|
|
16
|
+
from . import measurment_handler as meas
|
|
17
|
+
from .buffer import DataBuffer
|
|
18
|
+
from . import flac_stream_2_samples as flac2samples
|
|
19
|
+
from .tmpFileStateMachine import TmpfileStateMachine
|
|
20
|
+
from ..webxi import webxi_stream
|
|
21
|
+
from slm_api.enums.fast_log_intervals_enum import FastLogInterval
|
|
22
|
+
from .data_handler import MultiDataHandler, JsonDataHandler, CsvDataHandler, PickleDataHandler
|
|
23
|
+
|
|
24
|
+
_SAVING_HANDLERS = {
|
|
25
|
+
"json": JsonDataHandler,
|
|
26
|
+
"csv": CsvDataHandler,
|
|
27
|
+
"pickle": PickleDataHandler,
|
|
28
|
+
}
|
|
29
|
+
_SAVING_DEFAULT_EXTENSIONS = {"json": "json", "csv": "csv", "pickle": "pkl"}
|
|
30
|
+
|
|
31
|
+
# WebXi header timestamps are 64-bit fixed-point seconds since the Unix epoch
|
|
32
|
+
# (Q32.32): upper 32 bits = whole seconds, lower 32 bits = fraction of a second.
|
|
33
|
+
_WEBXI_TICKS_PER_SECOND = 2 ** 32
|
|
34
|
+
|
|
35
|
+
# ControlBBFastLoggingInterval enum -> milliseconds between fast-logged samples.
|
|
36
|
+
# Derived from FastLogInterval so it stays in sync if that enum changes
|
|
37
|
+
# (member value = device index, member name = "interval_<ms>ms").
|
|
38
|
+
_FAST_LOGGING_INTERVAL_MS = {
|
|
39
|
+
member.value: int(member.name.removeprefix("interval_").removesuffix("ms"))
|
|
40
|
+
for member in FastLogInterval
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _webxi_time_to_epoch(header_time):
|
|
45
|
+
"""Convert a WebXi header time (Q32.32 fixed-point) to Unix epoch seconds."""
|
|
46
|
+
return header_time / _WEBXI_TICKS_PER_SECOND
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_fast_logging_interval_s(host):
|
|
50
|
+
"""Read the configured broadband fast-logging interval and return it in seconds."""
|
|
51
|
+
enum = https_requests.get(host, "/WebXi/Applications/SLM/Setup/ControlBBFastLoggingInterval").json()
|
|
52
|
+
if enum not in _FAST_LOGGING_INTERVAL_MS:
|
|
53
|
+
raise ValueError(f"Unexpected ControlBBFastLoggingInterval value: {enum!r}")
|
|
54
|
+
return _FAST_LOGGING_INTERVAL_MS[enum] / 1000.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _set_fast_logging_interval(host, enum_value):
|
|
58
|
+
"""Configure the device's broadband fast-logging interval from an enum index (0-10)."""
|
|
59
|
+
if enum_value not in _FAST_LOGGING_INTERVAL_MS:
|
|
60
|
+
raise ValueError(f"Invalid ControlBBFastLoggingInterval enum: {enum_value!r}, must be one of {sorted(_FAST_LOGGING_INTERVAL_MS)}")
|
|
61
|
+
response = https_requests.put(host, "/WebXi/Applications/SLM/Setup/ControlBBFastLoggingInterval", json=enum_value)
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_slm_utc_offset_seconds(host):
|
|
66
|
+
"""Read the SLM's own configured time zone offset from UTC, in seconds.
|
|
67
|
+
The device reports it as a string like 'GMT+02:00'."""
|
|
68
|
+
response = https_requests.get(host, "/WebXi/Applications/SLM/Outputs/TimeZone")
|
|
69
|
+
response.raise_for_status()
|
|
70
|
+
value = str(response.json()).strip()
|
|
71
|
+
match = re.search(r"([+-])(\d{2}):?(\d{2})", value)
|
|
72
|
+
if not match:
|
|
73
|
+
raise ValueError(f"Unexpected TimeZone value: {value!r}")
|
|
74
|
+
sign, hours, minutes = match.groups()
|
|
75
|
+
offset = int(hours) * 3600 + int(minutes) * 60
|
|
76
|
+
return -offset if sign == "-" else offset
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _TimeStamps:
|
|
80
|
+
"""Tracks a stream's StartTime plus a rolling ElapsedTime buffer to derive a
|
|
81
|
+
wall-clock timestamp for each incoming data sample."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, buffer_size=101, utc_offset_seconds=None):
|
|
84
|
+
self._start_time = None
|
|
85
|
+
self._time_buffer = np.zeros(buffer_size)
|
|
86
|
+
self._utc_offset_seconds = utc_offset_seconds
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def StartTime(self):
|
|
90
|
+
return self._start_time
|
|
91
|
+
|
|
92
|
+
@StartTime.setter
|
|
93
|
+
def StartTime(self, value):
|
|
94
|
+
if self._start_time is None:
|
|
95
|
+
# Prefer the SLM's own UTC offset; fall back to the local PC clock's
|
|
96
|
+
# offset (accounts for DST, unlike time.timezone) if none was given.
|
|
97
|
+
offset = (
|
|
98
|
+
self._utc_offset_seconds if self._utc_offset_seconds is not None
|
|
99
|
+
else datetime.now().astimezone().utcoffset().total_seconds()
|
|
100
|
+
)
|
|
101
|
+
self._start_time = value + offset
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def lastTime(self):
|
|
105
|
+
return datetime.utcfromtimestamp(self._time_buffer[-1]).strftime("%H:%M:%S")
|
|
106
|
+
|
|
107
|
+
def move(self, new_value):
|
|
108
|
+
new_value = new_value + self.StartTime
|
|
109
|
+
self._time_buffer = np.append(self._time_buffer[1:], new_value)
|
|
110
|
+
return self.lastTime
|
|
111
|
+
|
|
112
|
+
def start_all_synced(streams):
|
|
113
|
+
"""For handlers constructed with multi_device=True: open each device's stream/websocket,
|
|
114
|
+
then trigger start_pause_measurement on all of them at (nearly) the same instant."""
|
|
115
|
+
for s in streams:
|
|
116
|
+
s.uri = stream.setup_stream(s.host, s.stream_ids, s.streamName)
|
|
117
|
+
threading.Thread(target=s.startStream, daemon=True).start()
|
|
118
|
+
|
|
119
|
+
barrier = threading.Barrier(len(streams))
|
|
120
|
+
|
|
121
|
+
def _start_one(s):
|
|
122
|
+
barrier.wait()
|
|
123
|
+
https_requests.put(s.host, "/WebXi/Applications/SLM?Action=StartPause")
|
|
124
|
+
|
|
125
|
+
start_threads = [threading.Thread(target=_start_one, args=(s,)) for s in streams]
|
|
126
|
+
for t in start_threads:
|
|
127
|
+
t.start()
|
|
128
|
+
for t in start_threads:
|
|
129
|
+
t.join()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def stop_all(streams):
|
|
133
|
+
"""Stop local websocket loops, synchronize measurement stops, then remove streams."""
|
|
134
|
+
for s in streams:
|
|
135
|
+
s.request_stop()
|
|
136
|
+
|
|
137
|
+
barrier = threading.Barrier(len(streams))
|
|
138
|
+
|
|
139
|
+
def _stop_one(s):
|
|
140
|
+
barrier.wait()
|
|
141
|
+
meas.stop_measurement(s.host)
|
|
142
|
+
|
|
143
|
+
stop_threads = [threading.Thread(target=_stop_one, args=(s,)) for s in streams]
|
|
144
|
+
for t in stop_threads:
|
|
145
|
+
t.start()
|
|
146
|
+
for t in stop_threads:
|
|
147
|
+
t.join()
|
|
148
|
+
|
|
149
|
+
for s in streams:
|
|
150
|
+
if s.data_handler is not None and hasattr(s.data_handler, "close"):
|
|
151
|
+
s.data_handler.close()
|
|
152
|
+
stream.delete_stream(s.host, s.streamName)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class WebXiStreamHandler(stream.StreamHandler):
|
|
156
|
+
"""Configurable WebXi stream handler.
|
|
157
|
+
|
|
158
|
+
mode= is the preferred way to pick the stream type. Valid values are
|
|
159
|
+
"leq", "cpb", "multi", "flac", "mp3", "fast_logging", and
|
|
160
|
+
"screen_record". Legacy Boolean mode flags remain supported for backwards
|
|
161
|
+
compatibility, but only one mode can be selected.
|
|
162
|
+
|
|
163
|
+
- mode="leq" (or no mode): single Leq sequence (e.g. LAeq) given by `sequenceID`,
|
|
164
|
+
printing the instantaneous value and a moving average over `leq_window_sec`.
|
|
165
|
+
- mode="cpb": CPB (constant percentage bandwidth) sequence looked up by the
|
|
166
|
+
first entry of `sequenceNames`.
|
|
167
|
+
- mode="multi": several sequences at once, given by `sequenceNames`
|
|
168
|
+
(default ["LAeq", "LCeq"]), each tracked with its own moving average
|
|
169
|
+
(`leq_window_sec`, `windowSize`).
|
|
170
|
+
- mode="flac": FLAC-encoded audio (`sequenceID`) decoded into DataBuffer.
|
|
171
|
+
- mode="mp3": mp3-encoded audio (`sequenceID`) decoded into DataBuffer.
|
|
172
|
+
- mode="fast_logging": broadband fast-logged Leq sequence (`sequenceID`), pretty-printed
|
|
173
|
+
with a per-sample timestamp. `fast_logging_interval` is the ControlBBFastLoggingInterval
|
|
174
|
+
enum index (0-10) to configure on the device before streaming; if omitted, the device's
|
|
175
|
+
current setting is read instead.
|
|
176
|
+
- mode="screen_record": streams the device's ScreenImage sequence, keeping the latest
|
|
177
|
+
received PNG in `self.image_bytes`.
|
|
178
|
+
|
|
179
|
+
time=True additionally subscribes the device's StartTime/ElapsedTime sequences and
|
|
180
|
+
prefixes each printed line (laeq/cpb/multi modes) with the resolved wall-clock time.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, host, sequenceID=None, sequenceNames=None,
|
|
184
|
+
streamName="Stream", cpb=False, multi=False, flac=False, mp3=False, fast_logging=False,
|
|
185
|
+
time=False, screen_record=False, multi_device=False, fast_logging_interval=None,
|
|
186
|
+
saving=None, saving_path=None, mode=None):
|
|
187
|
+
super().__init__(streamName)
|
|
188
|
+
legacy_modes = {
|
|
189
|
+
"cpb": cpb,
|
|
190
|
+
"multi": multi,
|
|
191
|
+
"flac": flac,
|
|
192
|
+
"mp3": mp3,
|
|
193
|
+
"fast_logging": fast_logging,
|
|
194
|
+
"screen_record": screen_record,
|
|
195
|
+
}
|
|
196
|
+
selected_legacy_modes = [name for name, enabled in legacy_modes.items() if enabled]
|
|
197
|
+
valid_modes = {"leq", *legacy_modes}
|
|
198
|
+
if mode is not None and mode not in valid_modes:
|
|
199
|
+
raise ValueError(f"Invalid mode: {mode!r}, must be one of {sorted(valid_modes)}")
|
|
200
|
+
if len(selected_legacy_modes) > 1:
|
|
201
|
+
raise ValueError(f"Only one legacy mode flag can be enabled; got {', '.join(selected_legacy_modes)}")
|
|
202
|
+
if mode is not None and selected_legacy_modes and mode != selected_legacy_modes[0]:
|
|
203
|
+
raise ValueError(f"mode={mode!r} conflicts with {selected_legacy_modes[0]}=True")
|
|
204
|
+
self.mode = mode or (selected_legacy_modes[0] if selected_legacy_modes else "leq")
|
|
205
|
+
self.host = host
|
|
206
|
+
self.sequenceID = sequenceID
|
|
207
|
+
self.sequenceNames = sequenceNames
|
|
208
|
+
self.cpb = self.mode == "cpb"
|
|
209
|
+
self.multi = self.mode == "multi"
|
|
210
|
+
self.flac = self.mode == "flac"
|
|
211
|
+
self.mp3 = self.mode == "mp3"
|
|
212
|
+
self.multi_device = multi_device
|
|
213
|
+
self.fast_logging = self.mode == "fast_logging"
|
|
214
|
+
self.time = time
|
|
215
|
+
self.screen_record = self.mode == "screen_record"
|
|
216
|
+
self.image_bytes = None
|
|
217
|
+
self.fast_logging_interval = fast_logging_interval
|
|
218
|
+
self.max_input = 15.6263 / np.sqrt(2)
|
|
219
|
+
self._saving_handler = None
|
|
220
|
+
if saving is not None:
|
|
221
|
+
if saving.lower() not in _SAVING_HANDLERS:
|
|
222
|
+
raise ValueError(f"Invalid saving format: {saving!r}, must be one of {list(_SAVING_HANDLERS)}")
|
|
223
|
+
if saving_path is None:
|
|
224
|
+
saving_path = "stream_data"
|
|
225
|
+
self._saving_handler = _SAVING_HANDLERS[saving.lower()](saving_path)
|
|
226
|
+
self.data_handler = self._saving_handler
|
|
227
|
+
if self.mp3:
|
|
228
|
+
self.tmpFileSM = TmpfileStateMachine()
|
|
229
|
+
self.streamInit()
|
|
230
|
+
|
|
231
|
+
def setDataHandler(self, data_handler):
|
|
232
|
+
"""Attach a DataHandler (see data_handler.py) before calling startStream().
|
|
233
|
+
Without one, WebXiStreamHandler stays silent and does nothing with decoded values.
|
|
234
|
+
If saving=... was set, data_handler is combined with the saving handler instead
|
|
235
|
+
of replacing it."""
|
|
236
|
+
if self._saving_handler is not None:
|
|
237
|
+
self.data_handler = MultiDataHandler(data_handler, self._saving_handler)
|
|
238
|
+
else:
|
|
239
|
+
self.data_handler = data_handler
|
|
240
|
+
|
|
241
|
+
def get_calibration_factor(self):
|
|
242
|
+
# Calculate calibration factor from the microphone sensitivity
|
|
243
|
+
response = https_requests.get(self.host, "/WebXi/Applications/SLM/Outputs/Sensitivity")
|
|
244
|
+
assert response.status_code == 200
|
|
245
|
+
mic_sens = float(response.text) # V/Pa
|
|
246
|
+
max_lvl = 20 * np.log10((self.max_input / mic_sens) / 20e-6) # dB SPL re 20 uPa
|
|
247
|
+
self.calibrationFactor = (20e-6 * 10 ** (max_lvl / 20)) / (2 ** 23 - 1) * np.sqrt(2)
|
|
248
|
+
|
|
249
|
+
def streamInit(self):
|
|
250
|
+
if self.mp3 or self.flac:
|
|
251
|
+
quality_setting = "AudioRecordingListenQuality" if self.mp3 else "AudioRecordingAnalysisQuality"
|
|
252
|
+
response = https_requests.put(self.host, f"/WebXi/Applications/SLM/setup/{quality_setting}", json=1)
|
|
253
|
+
assert response.status_code == 200
|
|
254
|
+
time.sleep(3) # gives the slm time to actually enable audioRecording
|
|
255
|
+
self.get_calibration_factor()
|
|
256
|
+
self.ID, self.sequence = seq.get_sequence(self.host, self.sequenceID)
|
|
257
|
+
elif self.cpb:
|
|
258
|
+
name = self.sequenceNames[0] if self.sequenceNames else "CPBLAeq"
|
|
259
|
+
self.ID, self.sequence = seq.get_sequence(self.host, seq.getSequenceID(self.host, name))
|
|
260
|
+
self.CPB_values = np.zeros(self.sequence["VectorLength"])
|
|
261
|
+
elif self.multi:
|
|
262
|
+
self.IDs, self.sequences = [], []
|
|
263
|
+
for name in (self.sequenceNames or ["LAeq", "LCeq"]):
|
|
264
|
+
ID, sequence = seq.get_sequence(self.host, seq.getSequenceID(self.host, name))
|
|
265
|
+
self.IDs.append(ID)
|
|
266
|
+
self.sequences.append(sequence)
|
|
267
|
+
self.ID = self.IDs
|
|
268
|
+
elif self.fast_logging:
|
|
269
|
+
self.ID, self.sequence = seq.get_sequence(self.host, self.sequenceID)
|
|
270
|
+
if self.fast_logging_interval is not None:
|
|
271
|
+
_set_fast_logging_interval(self.host, self.fast_logging_interval)
|
|
272
|
+
self.fast_logging_interval_s = _FAST_LOGGING_INTERVAL_MS[self.fast_logging_interval] / 1000.0
|
|
273
|
+
else:
|
|
274
|
+
self.fast_logging_interval_s = _get_fast_logging_interval_s(self.host)
|
|
275
|
+
print("=" * 60)
|
|
276
|
+
print(f" Streaming: {self.sequence.get('Name', 'FastLAeq')}")
|
|
277
|
+
print(f" Sequence: {self.sequenceID}")
|
|
278
|
+
print(f" Weighting: {self.sequence.get('AcousticalWeighting', 'A')}")
|
|
279
|
+
print(f" Unit: {self.sequence.get('Unit', 'dB re 20uPa')}")
|
|
280
|
+
print(f" Interval: {self.fast_logging_interval_s * 1000:.0f} ms/sample")
|
|
281
|
+
print("=" * 60)
|
|
282
|
+
elif self.screen_record:
|
|
283
|
+
self.ID, self.sequence = seq.get_sequence(self.host, seq.getSequenceID(self.host, "ScreenImage"))
|
|
284
|
+
else:
|
|
285
|
+
self.ID, self.sequence = seq.get_sequence(self.host, self.sequenceID)
|
|
286
|
+
self.data_type = self.sequence["DataType"]
|
|
287
|
+
|
|
288
|
+
stream_ids = list(self.ID) if isinstance(self.ID, list) else [self.ID]
|
|
289
|
+
self.stream_ids = stream_ids # saved for start_all_synced() when multi_device=True
|
|
290
|
+
if self.multi_device:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
if self.time:
|
|
294
|
+
utc_offset_seconds = _get_slm_utc_offset_seconds(self.host)
|
|
295
|
+
self._time_stamps = _TimeStamps(utc_offset_seconds=utc_offset_seconds)
|
|
296
|
+
start_id, _ = seq.get_sequence(self.host, seq.getSequenceID(self.host, "StartTime"))
|
|
297
|
+
elapsed_id, _ = seq.get_sequence(self.host, seq.getSequenceID(self.host, "ElapsedTime"))
|
|
298
|
+
self._time_ids = {"StartTime": start_id, "ElapsedTime": elapsed_id}
|
|
299
|
+
stream_ids += [start_id, elapsed_id]
|
|
300
|
+
self.uri = stream.setup_stream(self.host, stream_ids, self.streamName)
|
|
301
|
+
meas.start_pause_measurement(self.host, True)
|
|
302
|
+
|
|
303
|
+
def msg_func(self, message):
|
|
304
|
+
if self.time:
|
|
305
|
+
self._handle_time_blocks(message)
|
|
306
|
+
if self.mp3 or self.flac:
|
|
307
|
+
self._msg_func_audio(message)
|
|
308
|
+
elif self.cpb:
|
|
309
|
+
self._msg_func_cpb(message)
|
|
310
|
+
elif self.multi:
|
|
311
|
+
self._msg_func_multi(message)
|
|
312
|
+
elif self.fast_logging:
|
|
313
|
+
self._msg_func_fast_logging(message)
|
|
314
|
+
elif self.screen_record:
|
|
315
|
+
self._msg_func_screen_record(message)
|
|
316
|
+
else:
|
|
317
|
+
self._msg_func_laeq(message)
|
|
318
|
+
|
|
319
|
+
def _handle_time_blocks(self, message):
|
|
320
|
+
package = webxi_stream.WebxiStream.from_bytes(message)
|
|
321
|
+
if package.header.message_type != webxi_stream.WebxiStream.Header.EMessageType.e_sequence_data:
|
|
322
|
+
return
|
|
323
|
+
for block in package.content.sequence_blocks:
|
|
324
|
+
if block.sequence_id == self._time_ids["StartTime"]:
|
|
325
|
+
self._time_stamps.StartTime = stream.data_type_conv("BKTimeSpan", block.values, None)
|
|
326
|
+
elif block.sequence_id == self._time_ids["ElapsedTime"] and self._time_stamps.StartTime is not None:
|
|
327
|
+
self._time_stamps.move(stream.data_type_conv("BKTimeSpan", block.values, None))
|
|
328
|
+
|
|
329
|
+
def _time_prefix(self):
|
|
330
|
+
return f"{self._time_stamps.lastTime} " if self.time else ""
|
|
331
|
+
|
|
332
|
+
def _handle_data(self, **data):
|
|
333
|
+
"""Deliver a stream record with the host it originated from."""
|
|
334
|
+
if self.data_handler is not None:
|
|
335
|
+
self.data_handler.handle(host=self.host, **data)
|
|
336
|
+
|
|
337
|
+
def _msg_func_laeq(self, message):
|
|
338
|
+
package = webxi_stream.WebxiStream.from_bytes(message)
|
|
339
|
+
if package.header.message_type == webxi_stream.WebxiStream.Header.EMessageType.e_sequence_data:
|
|
340
|
+
value = package.content.sequence_blocks[0].values
|
|
341
|
+
leq_value = stream.data_type_conv(self.data_type, value, None) / 100
|
|
342
|
+
self._handle_data(timestamp=self._time_prefix(), value=leq_value)
|
|
343
|
+
|
|
344
|
+
def _msg_func_cpb(self, message):
|
|
345
|
+
package = webxi_stream.WebxiStream.from_bytes(message)
|
|
346
|
+
if package.header.message_type == webxi_stream.WebxiStream.Header.EMessageType.e_sequence_data:
|
|
347
|
+
value = package.content.sequence_blocks[0].values
|
|
348
|
+
self.CPB_values = np.array(stream.data_type_conv(self.sequence["DataType"], value, self.sequence["VectorLength"])) / 100
|
|
349
|
+
self._handle_data(timestamp=self._time_prefix(), value=self.CPB_values)
|
|
350
|
+
|
|
351
|
+
def calcFreqBands(self):
|
|
352
|
+
"""Only meaningful when cpb=True."""
|
|
353
|
+
octaveRange = np.arange(12, 44, 3) if self.sequence["VectorLength"] == 11 else np.arange(11, 44)
|
|
354
|
+
bb = lambda x: np.round(x * 2) / 2 if x < 70 else bb(x / 10) * 10
|
|
355
|
+
octaves = 10 ** (0.1 * octaveRange)
|
|
356
|
+
return [bb(i) for i in octaves]
|
|
357
|
+
|
|
358
|
+
def _msg_func_multi(self, message):
|
|
359
|
+
package = webxi_stream.WebxiStream.from_bytes(message)
|
|
360
|
+
if package.header.message_type == webxi_stream.WebxiStream.Header.EMessageType.e_sequence_data:
|
|
361
|
+
for ID, sequence in zip(self.IDs, self.sequences):
|
|
362
|
+
for data in package.content.sequence_blocks:
|
|
363
|
+
if data.sequence_id == ID:
|
|
364
|
+
value = stream.data_type_conv(sequence["DataType"], data.values, None)
|
|
365
|
+
value = (np.array(value) if isinstance(value, list) else value) / 100
|
|
366
|
+
seqName = sequence["Name"]
|
|
367
|
+
self._handle_data(timestamp=self._time_prefix(), name=seqName, value=value)
|
|
368
|
+
|
|
369
|
+
def _msg_func_fast_logging(self, message):
|
|
370
|
+
package = webxi_stream.WebxiStream.from_bytes(message)
|
|
371
|
+
if package.header.message_type != webxi_stream.WebxiStream.Header.EMessageType.e_sequence_data:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
# The header time is the time of the first sample; fall back to wall clock if
|
|
375
|
+
# it is not in the expected Q32.32 format/epoch.
|
|
376
|
+
start = _webxi_time_to_epoch(package.header.time)
|
|
377
|
+
if abs(start - time.time()) > 86400:
|
|
378
|
+
start = time.time()
|
|
379
|
+
|
|
380
|
+
# A 1-second package batches many fast-logged samples.
|
|
381
|
+
samples = []
|
|
382
|
+
for block in package.content.sequence_blocks:
|
|
383
|
+
if block.sequence_id != self.ID:
|
|
384
|
+
continue
|
|
385
|
+
vector_length = self.sequence.get("VectorLength")
|
|
386
|
+
if vector_length is None and self.sequence["DataType"] == "Int16":
|
|
387
|
+
vector_length = len(block.values) // 2
|
|
388
|
+
raw = stream.data_type_conv(self.sequence["DataType"], block.values, vector_length if vector_length and vector_length > 1 else None)
|
|
389
|
+
scale = self.sequence.get("Scale", 0.01)
|
|
390
|
+
if isinstance(raw, list):
|
|
391
|
+
samples.extend(v * scale for v in raw)
|
|
392
|
+
else:
|
|
393
|
+
samples.append(raw * scale)
|
|
394
|
+
|
|
395
|
+
if not samples:
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
name = self.sequence.get("Name", "FastLAeq")
|
|
399
|
+
local = self.sequence.get("LocalName", "LAeq")
|
|
400
|
+
unit = self.sequence.get("Unit", "dB re 20uPa")
|
|
401
|
+
for n, v in enumerate(samples):
|
|
402
|
+
self._handle_data(timestamp=self._time_prefix(), name=name, local=local, value=v, unit=unit)
|
|
403
|
+
|
|
404
|
+
def _msg_func_screen_record(self, message):
|
|
405
|
+
package = webxi_stream.WebxiStream.from_bytes(message)
|
|
406
|
+
if package.header.message_type != webxi_stream.WebxiStream.Header.EMessageType.e_sequence_data:
|
|
407
|
+
return
|
|
408
|
+
for block in package.content.sequence_blocks:
|
|
409
|
+
if block.sequence_id == self.ID and block.values:
|
|
410
|
+
# Keep only the most recently received image; older ones are discarded
|
|
411
|
+
self.image_bytes = bytes(block.values)
|
|
412
|
+
|
|
413
|
+
def _msg_func_audio(self, message):
|
|
414
|
+
start = timer()
|
|
415
|
+
package = webxi_stream.WebxiStream.from_bytes(message)
|
|
416
|
+
if package.header.message_type == webxi_stream.WebxiStream.Header.EMessageType.e_sequence_data:
|
|
417
|
+
block = package.content.sequence_blocks[0]
|
|
418
|
+
if self.mp3:
|
|
419
|
+
data = self.tmpFileSM.runStateMachine(block.frame)
|
|
420
|
+
mp3DecodedData = miniaudio.mp3_read_s16(data)
|
|
421
|
+
samples = mp3DecodedData.samples[-mp3DecodedData.num_frames:]
|
|
422
|
+
decoded = (np.asarray(samples, dtype=np.int32) << 8) * self.calibrationFactor
|
|
423
|
+
else:
|
|
424
|
+
decoded = flac2samples.decode(block, self.calibrationFactor)
|
|
425
|
+
DataBuffer.append(decoded)
|
|
426
|
+
self._handle_data(samples=decoded)
|
|
427
|
+
total = timer() - start
|
|
428
|
+
if 0.0625 < total:
|
|
429
|
+
print(f"TotalTime: {total}")
|
|
430
|
+
|
|
431
|
+
def stopStream(self):
|
|
432
|
+
if self.data_handler is not None and hasattr(self.data_handler, "close"):
|
|
433
|
+
self.data_handler.close()
|
|
434
|
+
super().stopStream()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import tempfile
|
|
3
|
+
|
|
4
|
+
# a dual bufferfile stores the data which is used for decoding the mp3 stream.
|
|
5
|
+
class TmpfileStateMachine:
|
|
6
|
+
# tmpfiles are used to store the mp3 data which are used for decoding.
|
|
7
|
+
class State(enum.Enum):
|
|
8
|
+
init = 0
|
|
9
|
+
file1 = 1
|
|
10
|
+
preFile2 = 2
|
|
11
|
+
file2 = 3
|
|
12
|
+
preFile1 = 4
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.tempFile1 = None
|
|
16
|
+
self.tempFile2 = None
|
|
17
|
+
self.state = self.State.init
|
|
18
|
+
|
|
19
|
+
def runStateMachine(self, mp3Data):
|
|
20
|
+
readData = None
|
|
21
|
+
match self.state:
|
|
22
|
+
case self.State.init:
|
|
23
|
+
readData = self.on_init(mp3Data)
|
|
24
|
+
case self.State.file1:
|
|
25
|
+
readData = self.on_file1(mp3Data)
|
|
26
|
+
case self.State.preFile2:
|
|
27
|
+
readData = self.on_preFile2(mp3Data)
|
|
28
|
+
case self.State.file2:
|
|
29
|
+
readData = self.on_file2(mp3Data)
|
|
30
|
+
case self.State.preFile1:
|
|
31
|
+
readData = self.on_preFile1(mp3Data)
|
|
32
|
+
return readData
|
|
33
|
+
|
|
34
|
+
def on_init(self, mp3Data):
|
|
35
|
+
if not self.tempFile1:
|
|
36
|
+
self.tempFile1 = tempfile.SpooledTemporaryFile(max_size=1 * 1024 * 1024, mode='w+b', suffix=".mp3",
|
|
37
|
+
prefix="tmp1")
|
|
38
|
+
self.tempFile1.write(bytes(mp3Data))
|
|
39
|
+
self.tempFile1.seek(0)
|
|
40
|
+
dataFromFile = self.tempFile1.read()
|
|
41
|
+
if (len(dataFromFile) > 20 * 1024):
|
|
42
|
+
self.state = self.State.preFile2
|
|
43
|
+
return dataFromFile
|
|
44
|
+
|
|
45
|
+
def on_file1(self, mp3Data):
|
|
46
|
+
if self.tempFile2:
|
|
47
|
+
self.tempFile2.close()
|
|
48
|
+
self.tempFile2 = None
|
|
49
|
+
self.tempFile1.seek(0)
|
|
50
|
+
dataFromFile = self.tempFile1.read()
|
|
51
|
+
self.tempFile1.write(bytes(mp3Data))
|
|
52
|
+
if (len(dataFromFile) > 20 * 1024):
|
|
53
|
+
self.state = self.State.preFile2
|
|
54
|
+
return dataFromFile
|
|
55
|
+
|
|
56
|
+
def on_preFile2(self, mp3Data):
|
|
57
|
+
if not self.tempFile2:
|
|
58
|
+
self.tempFile2 = tempfile.SpooledTemporaryFile(max_size=1 * 1024 * 1024, mode='w+b', suffix=".mp3",
|
|
59
|
+
prefix="tmp2")
|
|
60
|
+
self.tempFile1.seek(0)
|
|
61
|
+
self.tempFile2.seek(0)
|
|
62
|
+
dataFromFile = self.tempFile1.read()
|
|
63
|
+
tmp = self.tempFile2.read()
|
|
64
|
+
self.tempFile1.write(bytes(mp3Data))
|
|
65
|
+
self.tempFile2.write(bytes(mp3Data))
|
|
66
|
+
if (len(dataFromFile) > 30 * 1024):
|
|
67
|
+
self.state = self.State.file2
|
|
68
|
+
return dataFromFile
|
|
69
|
+
|
|
70
|
+
def on_file2(self, mp3Data):
|
|
71
|
+
if self.tempFile1:
|
|
72
|
+
self.tempFile1.close()
|
|
73
|
+
self.tempFile1 = None
|
|
74
|
+
self.tempFile2.seek(0)
|
|
75
|
+
dataFromFile = self.tempFile2.read()
|
|
76
|
+
self.tempFile2.write(bytes(mp3Data))
|
|
77
|
+
if (len(dataFromFile) > 20 * 1024):
|
|
78
|
+
self.state = self.State.preFile1
|
|
79
|
+
return dataFromFile
|
|
80
|
+
|
|
81
|
+
def on_preFile1(self, mp3Data):
|
|
82
|
+
if not self.tempFile1:
|
|
83
|
+
self.tempFile1 = tempfile.SpooledTemporaryFile(max_size=1 * 1024 * 1024, mode='w+b', suffix=".mp3",
|
|
84
|
+
prefix="tmp1")
|
|
85
|
+
self.tempFile1.seek(0)
|
|
86
|
+
self.tempFile2.seek(0)
|
|
87
|
+
dataFromFile = self.tempFile2.read()
|
|
88
|
+
tmp = self.tempFile1.read()
|
|
89
|
+
self.tempFile1.write(bytes(mp3Data))
|
|
90
|
+
self.tempFile2.write(bytes(mp3Data))
|
|
91
|
+
if (len(dataFromFile) > 30 * 1024):
|
|
92
|
+
self.state = self.State.file1
|
|
93
|
+
return dataFromFile
|