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.
Files changed (37) hide show
  1. slm_api/CPB_example.py +74 -0
  2. slm_api/__init__.py +5 -0
  3. slm_api/enums/fast_log_intervals_enum.py +16 -0
  4. slm_api/enums/sequence_id_enum.py +352 -0
  5. slm_api/example_multi.py +78 -0
  6. slm_api/fast_log_example.py +172 -0
  7. slm_api/flac_example.py +164 -0
  8. slm_api/helpers/FigureHandler.py +65 -0
  9. slm_api/helpers/Leq.py +87 -0
  10. slm_api/helpers/__init__.py +5 -0
  11. slm_api/helpers/buffer.py +27 -0
  12. slm_api/helpers/data_handler.py +94 -0
  13. slm_api/helpers/fft.py +38 -0
  14. slm_api/helpers/flac_stream_2_samples.py +64 -0
  15. slm_api/helpers/https_requests.py +79 -0
  16. slm_api/helpers/measurment_handler.py +20 -0
  17. slm_api/helpers/sequence_handler.py +43 -0
  18. slm_api/helpers/stream_handler.py +105 -0
  19. slm_api/helpers/stream_handlers.py +434 -0
  20. slm_api/helpers/tmpFileStateMachine.py +93 -0
  21. slm_api/helpers/websocket_handler.py +12 -0
  22. slm_api/helpers/webxi_helper_functions.py +79 -0
  23. slm_api/helpers/webxi_root.pem +24 -0
  24. slm_api/mp3_example.py +165 -0
  25. slm_api/multi_device_flac.py +105 -0
  26. slm_api/multi_instrument_example.py +74 -0
  27. slm_api/screen_record_example.py +64 -0
  28. slm_api/webxi/README.md +25 -0
  29. slm_api/webxi/__init__.py +5 -0
  30. slm_api/webxi/webxi-header.ksy +32 -0
  31. slm_api/webxi/webxi-stream.ksy +342 -0
  32. slm_api/webxi/webxi_header.py +42 -0
  33. slm_api/webxi/webxi_stream.py +450 -0
  34. slm_open_api_package-0.2.10.dist-info/METADATA +206 -0
  35. slm_open_api_package-0.2.10.dist-info/RECORD +37 -0
  36. slm_open_api_package-0.2.10.dist-info/WHEEL +4 -0
  37. 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