slm_open_api_package 0.2.10__tar.gz → 0.2.11__tar.gz

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 (39) hide show
  1. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/PKG-INFO +17 -4
  2. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/README.md +14 -3
  3. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/pyproject.toml +3 -1
  4. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/pyproject.toml.orig +3 -1
  5. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/CPB_example.py +10 -5
  6. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/example_multi.py +6 -2
  7. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/fast_log_example.py +19 -4
  8. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/flac_example.py +4 -1
  9. slm_open_api_package-0.2.11/src/slm_api/helpers/data_handler.py +251 -0
  10. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/stream_handlers.py +76 -49
  11. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/tmpFileStateMachine.py +36 -10
  12. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/webxi_helper_functions.py +2 -0
  13. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/mp3_example.py +5 -2
  14. slm_open_api_package-0.2.11/src/slm_api/plot_saved_data_example.py +163 -0
  15. slm_open_api_package-0.2.10/src/slm_api/helpers/data_handler.py +0 -94
  16. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/__init__.py +0 -0
  17. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/enums/fast_log_intervals_enum.py +0 -0
  18. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/enums/sequence_id_enum.py +0 -0
  19. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/FigureHandler.py +0 -0
  20. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/Leq.py +0 -0
  21. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/__init__.py +0 -0
  22. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/buffer.py +0 -0
  23. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/fft.py +0 -0
  24. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/flac_stream_2_samples.py +0 -0
  25. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/https_requests.py +0 -0
  26. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/measurment_handler.py +0 -0
  27. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/sequence_handler.py +0 -0
  28. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/stream_handler.py +0 -0
  29. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/websocket_handler.py +0 -0
  30. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/helpers/webxi_root.pem +0 -0
  31. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/multi_device_flac.py +0 -0
  32. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/multi_instrument_example.py +0 -0
  33. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/screen_record_example.py +0 -0
  34. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/webxi/README.md +0 -0
  35. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/webxi/__init__.py +0 -0
  36. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/webxi/webxi-header.ksy +0 -0
  37. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/webxi/webxi-stream.ksy +0 -0
  38. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/webxi/webxi_header.py +0 -0
  39. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.11}/src/slm_api/webxi/webxi_stream.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slm_open_api_package
3
- Version: 0.2.10
3
+ Version: 0.2.11
4
4
  Summary: Add your description here
5
5
  Author: Sofus Vingaard
6
6
  Author-email: Sofus Vingaard <sofus.krebsvingaard@hbkworld.com>
7
7
  Requires-Dist: requests
8
8
  Requires-Dist: numpy
9
+ Requires-Dist: pandas
9
10
  Requires-Dist: websockets
10
11
  Requires-Dist: kaitaistruct
11
12
  Requires-Dist: packaging
@@ -13,6 +14,7 @@ Requires-Dist: pyqtgraph
13
14
  Requires-Dist: pyqt5
14
15
  Requires-Dist: pyqt5-qt5<5.15.11
15
16
  Requires-Dist: miniaudio
17
+ Requires-Dist: soundfile
16
18
  Requires-Dist: matplotlib>=3.11.1
17
19
  Requires-Dist: cryptography>=50.0.1
18
20
  Requires-Python: >=3.12
@@ -78,7 +80,7 @@ except KeyboardInterrupt:
78
80
  streamer.stopStream()
79
81
  ```
80
82
 
81
- `stopStream()` stops the measurement, removes the device stream, and writes buffered saved data. Supported saving formats are `"csv"`, `"json"`, and `"pickle"`; their extensions are added automatically.
83
+ JSON and CSV publish complete snapshots in a background worker, at most once per second while new data arrives, rather than rewriting the file for every sample. They retain records in memory and preserve their existing layouts; full-history snapshot costs still grow with recording length. Construct `JsonDataHandler(path, snapshot_interval=2.0)` or `CsvDataHandler(path, snapshot_interval=2.0)` and attach it with `setDataHandler()` to use a different interval. `close()` waits for the worker and saves all remaining records; background write failures are reported on subsequent updates or during the final flush. WAV still appends each audio chunk without buffering the recording in memory. `stopStream()` stops the measurement, closes saving handlers, and removes the device stream. Measurement saving formats are `"csv"` and `"json"`; audio uses `"wav"`. File extensions are added automatically.
82
84
 
83
85
  ## Multiple Sequences
84
86
 
@@ -147,11 +149,22 @@ streamer = WebXiStreamHandler(
147
149
  host="192.168.1.183",
148
150
  sequenceID=SequenceIdEnums.MP3Signal.value,
149
151
  mode="mp3",
152
+ saving="wav",
153
+ saving_path="saved_data/audio",
150
154
  )
155
+
156
+ try:
157
+ streamer.startStream()
158
+ except KeyboardInterrupt:
159
+ streamer.stopStream()
151
160
  ```
152
161
 
153
162
  The matching `flac` form uses `SequenceIdEnums.FLACSignal.value` and `mode="flac"`. Audio setup is enabled by the handler. The SLM can take a few seconds to initialize audio recording when its quality setting changes.
154
163
 
164
+ WAV saving appends decoded mono samples as recording happens, updating the file header after each chunk so the file is readable before shutdown. Saving happens after decoding and calibration to pascals. Samples are divided by the fixed full-scale pressure `calibrationFactor * (2**23 - 1)` and saved as `PCM_24`, not peak-normalized per chunk. The filename includes `_fullscale-<value>Pa.wav`. To recover pressure values, multiply floating-point samples read from the WAV by that full-scale value (within 24-bit quantization accuracy). Samples outside the calibrated range produce a clipping warning; this scaling cannot repair upstream clipping. MP3 uses the decoded sample rate; the current FLAC decoder uses 65536 Hz. JSON and CSV saving are rejected for audio modes, and WAV saving is rejected for measurement modes. Run `uv sync` to install the `soundfile` dependency used for progressive WAV writing.
165
+
166
+ To attach the handler directly, use `WavDataHandler("saved_data/audio", sample_rate=65536, full_scale_pa=calibrationFactor * (2**23 - 1))` from `slm_api.helpers.data_handler`, using the device's actual calibration factor. You can instead supply `sample_rate` and `full_scale_pa` to `handle()`; the stream supplies both automatically. Call `close()` when finished. Use a separate WAV handler and output path for each device. Existing float32 pressure WAVs are unchanged; this format applies to new recordings.
167
+
155
168
  ## Synchronized Multi-Device Stream
156
169
 
157
170
  Based on [multi_device_flac.py](src/slm_api/multi_device_flac.py), pass `multi_device=True` and start/stop the group through the synchronized helpers:
@@ -168,7 +181,7 @@ streams = [
168
181
  mode="mp3",
169
182
  streamName=f"Audio_{host}",
170
183
  multi_device=True,
171
- saving="csv",
184
+ saving="wav",
172
185
  saving_path=f"saved_data/{host}",
173
186
  )
174
187
  for host in hosts
@@ -203,4 +216,4 @@ Legacy flags such as `mp3=True` remain supported, but `mode=` is preferred. Only
203
216
 
204
217
  - **No values arrive:** Confirm the requested sequences are enabled on the SLM.
205
218
  - **Audio returns no samples after first enable:** Allow the SLM a few seconds to initialize audio recording before retrying.
206
- - **Saved file is missing:** Call `stopStream()` or `stop_all()`; buffered files are written during shutdown.
219
+ - **Saved file is missing:** Files are written when the handler first receives data (nonempty samples for WAV). Check the saving path and that the stream is delivering data. Still call `stopStream()` or `stop_all()` to clean up recording resources.
@@ -58,7 +58,7 @@ except KeyboardInterrupt:
58
58
  streamer.stopStream()
59
59
  ```
60
60
 
61
- `stopStream()` stops the measurement, removes the device stream, and writes buffered saved data. Supported saving formats are `"csv"`, `"json"`, and `"pickle"`; their extensions are added automatically.
61
+ JSON and CSV publish complete snapshots in a background worker, at most once per second while new data arrives, rather than rewriting the file for every sample. They retain records in memory and preserve their existing layouts; full-history snapshot costs still grow with recording length. Construct `JsonDataHandler(path, snapshot_interval=2.0)` or `CsvDataHandler(path, snapshot_interval=2.0)` and attach it with `setDataHandler()` to use a different interval. `close()` waits for the worker and saves all remaining records; background write failures are reported on subsequent updates or during the final flush. WAV still appends each audio chunk without buffering the recording in memory. `stopStream()` stops the measurement, closes saving handlers, and removes the device stream. Measurement saving formats are `"csv"` and `"json"`; audio uses `"wav"`. File extensions are added automatically.
62
62
 
63
63
  ## Multiple Sequences
64
64
 
@@ -127,11 +127,22 @@ streamer = WebXiStreamHandler(
127
127
  host="192.168.1.183",
128
128
  sequenceID=SequenceIdEnums.MP3Signal.value,
129
129
  mode="mp3",
130
+ saving="wav",
131
+ saving_path="saved_data/audio",
130
132
  )
133
+
134
+ try:
135
+ streamer.startStream()
136
+ except KeyboardInterrupt:
137
+ streamer.stopStream()
131
138
  ```
132
139
 
133
140
  The matching `flac` form uses `SequenceIdEnums.FLACSignal.value` and `mode="flac"`. Audio setup is enabled by the handler. The SLM can take a few seconds to initialize audio recording when its quality setting changes.
134
141
 
142
+ WAV saving appends decoded mono samples as recording happens, updating the file header after each chunk so the file is readable before shutdown. Saving happens after decoding and calibration to pascals. Samples are divided by the fixed full-scale pressure `calibrationFactor * (2**23 - 1)` and saved as `PCM_24`, not peak-normalized per chunk. The filename includes `_fullscale-<value>Pa.wav`. To recover pressure values, multiply floating-point samples read from the WAV by that full-scale value (within 24-bit quantization accuracy). Samples outside the calibrated range produce a clipping warning; this scaling cannot repair upstream clipping. MP3 uses the decoded sample rate; the current FLAC decoder uses 65536 Hz. JSON and CSV saving are rejected for audio modes, and WAV saving is rejected for measurement modes. Run `uv sync` to install the `soundfile` dependency used for progressive WAV writing.
143
+
144
+ To attach the handler directly, use `WavDataHandler("saved_data/audio", sample_rate=65536, full_scale_pa=calibrationFactor * (2**23 - 1))` from `slm_api.helpers.data_handler`, using the device's actual calibration factor. You can instead supply `sample_rate` and `full_scale_pa` to `handle()`; the stream supplies both automatically. Call `close()` when finished. Use a separate WAV handler and output path for each device. Existing float32 pressure WAVs are unchanged; this format applies to new recordings.
145
+
135
146
  ## Synchronized Multi-Device Stream
136
147
 
137
148
  Based on [multi_device_flac.py](src/slm_api/multi_device_flac.py), pass `multi_device=True` and start/stop the group through the synchronized helpers:
@@ -148,7 +159,7 @@ streams = [
148
159
  mode="mp3",
149
160
  streamName=f"Audio_{host}",
150
161
  multi_device=True,
151
- saving="csv",
162
+ saving="wav",
152
163
  saving_path=f"saved_data/{host}",
153
164
  )
154
165
  for host in hosts
@@ -183,4 +194,4 @@ Legacy flags such as `mp3=True` remain supported, but `mode=` is preferred. Only
183
194
 
184
195
  - **No values arrive:** Confirm the requested sequences are enabled on the SLM.
185
196
  - **Audio returns no samples after first enable:** Allow the SLM a few seconds to initialize audio recording before retrying.
186
- - **Saved file is missing:** Call `stopStream()` or `stop_all()`; buffered files are written during shutdown.
197
+ - **Saved file is missing:** Files are written when the handler first receives data (nonempty samples for WAV). Check the saving path and that the stream is delivering data. Still call `stopStream()` or `stop_all()` to clean up recording resources.
@@ -1,12 +1,13 @@
1
1
  [project]
2
2
  name = "slm_open_api_package"
3
- version = "0.2.10"
3
+ version = "0.2.11"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  dependencies = [
8
8
  "requests",
9
9
  "numpy",
10
+ "pandas",
10
11
  "websockets",
11
12
  "kaitaistruct",
12
13
  "packaging",
@@ -14,6 +15,7 @@ dependencies = [
14
15
  "PyQt5",
15
16
  "PyQt5-Qt5<5.15.11",
16
17
  "miniaudio",
18
+ "soundfile",
17
19
  "matplotlib>=3.11.1",
18
20
  "cryptography>=50.0.1",
19
21
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slm_open_api_package"
3
- version = "0.2.10"
3
+ version = "0.2.11"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -10,6 +10,7 @@ requires-python = ">=3.12"
10
10
  dependencies = [
11
11
  "requests",
12
12
  "numpy",
13
+ "pandas",
13
14
  "websockets",
14
15
  "kaitaistruct",
15
16
  "packaging",
@@ -17,6 +18,7 @@ dependencies = [
17
18
  "PyQt5",
18
19
  "PyQt5-Qt5<5.15.11",
19
20
  "miniaudio",
21
+ "soundfile",
20
22
  "matplotlib>=3.11.1",
21
23
  "cryptography>=50.0.1",
22
24
  ]
@@ -6,7 +6,8 @@ from matplotlib.animation import FuncAnimation
6
6
  from slm_api.helpers.stream_handlers import WebXiStreamHandler
7
7
  from slm_api.helpers import webxi_helper_functions as webxi_helper
8
8
  from slm_api.enums.sequence_id_enum import SequenceIdEnums
9
- from slm_api.helpers.data_handler import CsvDataHandler,PickleDataHandler,JsonDataHandler,MultiDataHandler, DataHandler
9
+ from slm_api.helpers.data_handler import CsvDataHandler,JsonDataHandler,MultiDataHandler, DataHandler
10
+ import datetime
10
11
 
11
12
 
12
13
  host = webxi_helper.set_host(__file__)
@@ -17,8 +18,8 @@ cpb_sequence_names = ["CPBLAeq"]
17
18
  sequenceID= SequenceIdEnums.CPBLAeq.value
18
19
 
19
20
  class PrintHandler(DataHandler):
20
- def handle(self, value, **data ):
21
- print(f"{value}")
21
+ def handle(self, timestamp, value, **data ):
22
+ print(f"time = {timestamp} value: {value}")
22
23
 
23
24
 
24
25
  class FigureHandler:
@@ -59,12 +60,16 @@ if __name__ == "__main__":
59
60
  # turns on the CPB eq we want to use
60
61
  webxi_helper.turn_on_cpb_leq(host, cpb_sequence_names)
61
62
 
63
+ filename = f'name-of-file_{datetime.datetime.now().strftime("%H%M_%m%d%Y")}'
64
+
65
+
62
66
  streamer = WebXiStreamHandler(host,
63
67
  sequenceID=sequenceID,
64
68
  mode="cpb",
65
69
  sequenceNames=cpb_sequence_names,
66
- saving_path=r"./saved_data/test_file",
67
- saving="json")
70
+ saving_path=f"./saved_data/{filename}",
71
+ saving="json",
72
+ time=True)
68
73
  # streamer.setDataHandler(JsonDataHandler(r"C:\Users\SKREBSVINGAA\csv_test\csv_test.txt"))
69
74
  streamer.setDataHandler(PrintHandler())
70
75
 
@@ -8,6 +8,8 @@ from matplotlib.animation import FuncAnimation
8
8
  from slm_api.helpers import webxi_helper_functions as webxi_helper
9
9
  from slm_api.helpers import stream_handlers as sh
10
10
  from slm_api.helpers.data_handler import DataHandler, MultiDataHandler
11
+ import datetime
12
+
11
13
 
12
14
  # UseHttps(use_https=False)
13
15
 
@@ -15,7 +17,7 @@ host = webxi_helper.set_host(__file__)
15
17
 
16
18
  # This example will stream LAeq. Add more names to the list to plot several sequences at once.
17
19
  # Incase of error make sure the sequences are enabled on the SLM.
18
- sequenceNames = ["LAeq", "LCeq", "LAF"]
20
+ sequenceNames = ["LAeq", "LCeq"]
19
21
 
20
22
  class PrintHandler(DataHandler):
21
23
  def handle(self, *, name, value, **data):
@@ -70,7 +72,9 @@ if __name__ == "__main__":
70
72
  # sets the sequences to true. You can add or remove sequences at the top of the file.
71
73
  webxi_helper.turn_on_bb_leq(host, sequenceNames)
72
74
 
73
- streamer = sh.WebXiStreamHandler(host=host, sequenceNames=sequenceNames, mode="multi", saving="json", saving_path=f"./saved_data/test")
75
+ filename = f'name-of-file_{datetime.datetime.now().strftime("%H%M_%m%d%Y")}'
76
+
77
+ streamer = sh.WebXiStreamHandler(host=host, sequenceNames=sequenceNames, time=True, mode="multi", saving="csv", saving_path=f"./saved_data/{filename}")
74
78
  fig = FigHandler(sequenceNames)
75
79
  streamer.setDataHandler(MultiDataHandler(PrintHandler(), fig))
76
80
  fig.startAnimation()
@@ -36,6 +36,7 @@ from slm_api.helpers import webxi_helper_functions as webxi_helper
36
36
  # enums
37
37
  from slm_api.enums.sequence_id_enum import SequenceIdEnums
38
38
  from slm_api.enums.fast_log_intervals_enum import FastLogInterval
39
+ import datetime
39
40
 
40
41
  # ---------- Configuration ----------
41
42
  host = webxi_helper.set_host(__file__)
@@ -52,8 +53,8 @@ FAST_LOGGING_INTERVAL_MS = FastLogInterval.interval_1000ms.value
52
53
  SEQUENCE_ID = SequenceIdEnums.FastLAF.value
53
54
 
54
55
  class PrintDataHandler(DataHandler):
55
- def handle(self, timestamp, name, local, value, unit):
56
- print(f"{timestamp}{name} ({local}): {value:7.2f} {unit}")
56
+ def handle(self, timestamp, name, local, value, unit, **data):
57
+ print(f"{timestamp} {name} ({local}): {value:7.2f} {unit}")
57
58
 
58
59
 
59
60
 
@@ -150,14 +151,28 @@ class PrintDataHandler(DataHandler):
150
151
 
151
152
 
152
153
  if __name__ == "__main__":
154
+
155
+ filename = f'name-of-file_{datetime.datetime.now().strftime("%H%M_%m%d%Y")}'
156
+
157
+
158
+ # turns off all BB freq weights to prevent interference
159
+ webxi_helper.turn_off_bb_freq_weight(host)
160
+
161
+ # turns on the wanted BB freq weights for this example
162
+ webxi_helper.turn_on_bb_freq_weight(host, ["A"], fast=True)
163
+
164
+ # sets the sequences to true.
165
+ # For fastlogging add fast=True so it routes to the correct endpoint
166
+ webxi_helper.turn_on_bb_leq(host, "LAF", fast=True)
153
167
 
154
168
  try:
155
169
  streamer = WebXiStreamHandler(host,
156
170
  sequenceID = SEQUENCE_ID,
157
171
  mode="fast_logging",
158
172
  fast_logging_interval=FAST_LOGGING_INTERVAL_MS,
159
- saving="csv",
160
- saving_path=r"C:\Users\SKREBSVINGAA\csv_test\csv_test.txt")
173
+ saving="json",
174
+ saving_path=f"./saved_data/{filename}",
175
+ time=True)
161
176
  streamer.setDataHandler(PrintDataHandler())
162
177
 
163
178
 
@@ -29,6 +29,7 @@ import threading
29
29
 
30
30
  from slm_api.helpers.FigureHandler import FigureHandler
31
31
  from slm_api.helpers.data_handler import MultiDataHandler, CsvDataHandler
32
+ import datetime
32
33
 
33
34
 
34
35
  # FLAC streaming is only available on 2255
@@ -153,8 +154,10 @@ def on_close():
153
154
  streamer.stopStream()
154
155
 
155
156
  if __name__ == "__main__":
157
+ filename = f'name-of-file_{datetime.datetime.now().strftime("%H%M_%m%d%Y")}'
158
+
156
159
  # streamer = streamHandler()
157
- streamer = sh.WebXiStreamHandler(host, sequenceID=sequenceID, mode="flac", saving="csv", saving_path=r"C:\Users\SKREBSVINGAA\csv_test\csv_test.txt")
160
+ streamer = sh.WebXiStreamHandler(host, sequenceID=sequenceID, mode="flac", saving="wav", saving_path=f"./saved_data/flac_{filename}")
158
161
  # streamer.setDataHandler(MultiDataHandler(PrintDataHandler(), CsvDataHandler(r"C:\Users\SKREBSVINGAA\csv_test\csv_test.txt")))
159
162
  fig = figureHandler()
160
163
  fig.app.aboutToQuit.connect(on_close)
@@ -0,0 +1,251 @@
1
+ """Optional DataHandler for WebXiStreamHandler. By default WebXiStreamHandler
2
+ does nothing with decoded values - call streamer.setDataHandler(...) with an
3
+ instance of one of these (or your own subclass) before startStream() to
4
+ receive/print/save them."""
5
+
6
+ import csv
7
+ import json
8
+ import tempfile
9
+ import threading
10
+ import warnings
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+ import numpy as np
14
+ import soundfile as sf
15
+
16
+
17
+ class DataHandler:
18
+ """Override handle() to decide what happens with decoded stream values.
19
+ """
20
+
21
+
22
+ def handle(self, **data):
23
+ pass
24
+
25
+
26
+ class _BufferedFileDataHandler(DataHandler):
27
+ """Buffers records and publishes snapshots in a background worker.
28
+ Call close() to stop the worker and save remaining records. WAV writes chunks.
29
+ """
30
+
31
+ _extension = None # set by subclasses, e.g. "json", "csv", "wav"
32
+
33
+ def __init__(self, path, snapshot_interval=1.0):
34
+ if not np.isfinite(snapshot_interval) or snapshot_interval <= 0:
35
+ raise ValueError("Snapshot interval must be positive and finite")
36
+ path = Path(path)
37
+ if self._extension and path.suffix.lower() != f".{self._extension}":
38
+ path = path.with_name(path.name + f".{self._extension}")
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ self._path = path
41
+ self._records = []
42
+ self._lock = threading.RLock()
43
+ self._write_lock = threading.Lock()
44
+ self._snapshot_interval = snapshot_interval
45
+ self._stop_event = threading.Event()
46
+ self._worker = None
47
+ self._saved_count = -1
48
+ self._closed = False
49
+ self._write_error = None
50
+
51
+ def handle(self, **data):
52
+ with self._lock:
53
+ if self._closed:
54
+ return
55
+ if self._write_error is not None:
56
+ raise RuntimeError("Background snapshot saving failed") from self._write_error
57
+ if "values" in data:
58
+ data["values"] = [float(v) for v in data["values"]]
59
+ elif "samples" in data:
60
+ samples = data.pop("samples")
61
+ data.update(count=len(samples), min=float(min(samples)), max=float(max(samples)))
62
+ self._records.append(data)
63
+ if self._worker is None:
64
+ self._worker = threading.Thread(
65
+ target=self._run_snapshots, name=f"snapshot-{self._path.name}", daemon=True,
66
+ )
67
+ self._worker.start()
68
+
69
+ def _run_snapshots(self):
70
+ while not self._stop_event.wait(self._snapshot_interval):
71
+ try:
72
+ self._flush_snapshot()
73
+ except Exception as error:
74
+ with self._lock:
75
+ self._write_error = error
76
+ self._stop_event.set()
77
+ return
78
+
79
+ def _flush_snapshot(self):
80
+ with self._write_lock:
81
+ with self._lock:
82
+ count = len(self._records)
83
+ if count == self._saved_count:
84
+ return
85
+ records = self._records.copy()
86
+ self._write_snapshot(records)
87
+ with self._lock:
88
+ self._saved_count = count
89
+
90
+ def _write_snapshot(self, records):
91
+ raise NotImplementedError
92
+
93
+ @contextmanager
94
+ def _snapshot_file(self, mode, **options):
95
+ temporary_path = None
96
+ try:
97
+ with tempfile.NamedTemporaryFile(
98
+ mode=mode, dir=self._path.parent, prefix=f".{self._path.name}.",
99
+ suffix=".tmp", delete=False, **options,
100
+ ) as target:
101
+ temporary_path = Path(target.name)
102
+ yield target
103
+ temporary_path.replace(self._path)
104
+ finally:
105
+ if temporary_path is not None:
106
+ temporary_path.unlink(missing_ok=True)
107
+
108
+ def close(self):
109
+ with self._lock:
110
+ self._closed = True
111
+ self._stop_event.set()
112
+ worker = self._worker
113
+ if worker is not None:
114
+ worker.join()
115
+ self._flush_snapshot()
116
+
117
+ def _group_records(self, records=None):
118
+ hosts = {}
119
+ for record in self._records if records is None else records:
120
+ row = record.copy()
121
+ if not row.get("timestamp"):
122
+ row.pop("timestamp", None)
123
+ host = row.pop("host", None)
124
+ rows = hosts.setdefault(host, [])
125
+ if "name" in row and "value" in row:
126
+ name = row.pop("name")
127
+ value = row.pop("value")
128
+ if (rows and rows[-1].get("timestamp") == row.get("timestamp")
129
+ and name not in rows[-1]):
130
+ rows[-1].update(row)
131
+ rows[-1][name] = value
132
+ else:
133
+ row[name] = value
134
+ rows.append(row)
135
+ else:
136
+ rows.append(row)
137
+ return hosts
138
+
139
+
140
+ class JsonDataHandler(_BufferedFileDataHandler):
141
+ """Writes records grouped by host, combining named values per timestamp."""
142
+ _extension = "json"
143
+
144
+ def _write_snapshot(self, records):
145
+ with self._snapshot_file("w", encoding="utf-8") as target:
146
+ records = [{"host": host, "data": rows} for host, rows in self._group_records(records).items()]
147
+ json.dump(
148
+ records,
149
+ target,
150
+ indent=2,
151
+ default=lambda value: (
152
+ value.tolist()
153
+ if isinstance(value, (np.ndarray, np.generic))
154
+ else str(value)
155
+ ),
156
+ )
157
+
158
+ class CsvDataHandler(_BufferedFileDataHandler):
159
+ """Writes rows per host and timestamp, storing vectors as JSON list cells."""
160
+ _extension = "csv"
161
+
162
+ def _write_snapshot(self, records):
163
+ with self._snapshot_file("w", encoding="utf-8", newline="") as target:
164
+ records = [
165
+ {"host": host, **row}
166
+ for host, rows in self._group_records(records).items()
167
+ for row in rows
168
+ ]
169
+ for record in records:
170
+ for name, value in record.items():
171
+ if isinstance(value, np.ndarray):
172
+ record[name] = json.dumps(value.tolist())
173
+ elif isinstance(value, (list, tuple)):
174
+ record[name] = json.dumps(value)
175
+ fields = dict.fromkeys(key for record in records for key in record)
176
+ fieldnames = [key for key in ("host", "timestamp") if key in fields]
177
+ fieldnames.extend(key for key in fields if key not in fieldnames)
178
+ writer = csv.DictWriter(target, fieldnames=fieldnames)
179
+ writer.writeheader()
180
+ writer.writerows(records)
181
+
182
+
183
+ class WavDataHandler(_BufferedFileDataHandler):
184
+ """Writes PCM_24 chunks scaled by the full-scale pressure in the filename."""
185
+ _extension = "wav"
186
+
187
+ def __init__(self, path, sample_rate=None, full_scale_pa=None):
188
+ super().__init__(path)
189
+ self._sample_rate = sample_rate
190
+ self._full_scale_pa = full_scale_pa
191
+ self._started = False
192
+ self._closed = False
193
+
194
+ def handle(self, *, samples, sample_rate=None, full_scale_pa=None, **data):
195
+ with self._lock:
196
+ if self._closed:
197
+ return
198
+ rate = sample_rate if sample_rate is not None else self._sample_rate
199
+ if rate is None or rate <= 0 or int(rate) != rate:
200
+ raise ValueError("WAV samples require a positive integer sample rate")
201
+ if self._sample_rate is not None and rate != self._sample_rate:
202
+ raise ValueError("Sample rate cannot change within a WAV file")
203
+ scale = full_scale_pa if full_scale_pa is not None else self._full_scale_pa
204
+ if scale is None or not np.isfinite(scale) or scale <= 0:
205
+ raise ValueError("PCM_24 WAV samples require a positive finite full_scale_pa")
206
+ if self._full_scale_pa is not None and scale != self._full_scale_pa:
207
+ raise ValueError("Full-scale pressure cannot change within a WAV file")
208
+ values = np.asarray(samples, dtype=np.float64)
209
+ if values.ndim != 1:
210
+ raise ValueError("WAV samples must be a mono, one-dimensional array")
211
+ if not np.isfinite(values).all():
212
+ raise ValueError("WAV samples must be finite")
213
+ self._sample_rate = int(rate)
214
+ self._full_scale_pa = float(scale)
215
+ if values.size:
216
+ normalized = values / self._full_scale_pa
217
+ if np.any(normalized > 1.0) or np.any(normalized < -(2 ** 23) / (2 ** 23 - 1)):
218
+ warnings.warn("Audio exceeds the calibrated full-scale pressure; PCM_24 samples will clip", RuntimeWarning, stacklevel=2)
219
+ normalized = np.clip(normalized, -1.0, 1.0)
220
+ if not self._started:
221
+ self._path = self._path.with_name(
222
+ f"{self._path.stem}_fullscale-{self._full_scale_pa:.17g}Pa.wav"
223
+ )
224
+ options = {"mode": "r+"} if self._started else {
225
+ "mode": "w", "samplerate": self._sample_rate,
226
+ "channels": 1, "format": "WAV", "subtype": "PCM_24",
227
+ }
228
+ with sf.SoundFile(self._path, **options) as target:
229
+ target.seek(0, sf.SEEK_END)
230
+ target.write(normalized)
231
+ self._started = True
232
+
233
+ def close(self):
234
+ with self._lock:
235
+ self._closed = True
236
+
237
+
238
+ class MultiDataHandler(DataHandler):
239
+ """Fans out to several handlers at once (e.g. print AND save AND plot)."""
240
+
241
+ def __init__(self, *handlers):
242
+ self._handlers = handlers
243
+
244
+ def handle(self, **data):
245
+ for h in self._handlers:
246
+ h.handle(**data)
247
+
248
+ def close(self):
249
+ for h in self._handlers:
250
+ if hasattr(h, "close"):
251
+ h.close()
@@ -5,7 +5,7 @@ is picked with boolean flags (cpb, multi, flac, mp3) instead of subclassing."""
5
5
  from . import https_requests
6
6
  import time
7
7
  import re
8
- from datetime import datetime
8
+ from datetime import datetime, timedelta, timezone
9
9
  from timeit import default_timer as timer
10
10
  import numpy as np
11
11
  import miniaudio
@@ -19,14 +19,14 @@ from . import flac_stream_2_samples as flac2samples
19
19
  from .tmpFileStateMachine import TmpfileStateMachine
20
20
  from ..webxi import webxi_stream
21
21
  from slm_api.enums.fast_log_intervals_enum import FastLogInterval
22
- from .data_handler import MultiDataHandler, JsonDataHandler, CsvDataHandler, PickleDataHandler
22
+ from .data_handler import MultiDataHandler, JsonDataHandler, CsvDataHandler, WavDataHandler
23
23
 
24
24
  _SAVING_HANDLERS = {
25
25
  "json": JsonDataHandler,
26
26
  "csv": CsvDataHandler,
27
- "pickle": PickleDataHandler,
27
+ "wav": WavDataHandler,
28
28
  }
29
- _SAVING_DEFAULT_EXTENSIONS = {"json": "json", "csv": "csv", "pickle": "pkl"}
29
+ _SAVING_DEFAULT_EXTENSIONS = {"json": "json", "csv": "csv", "wav": "wav"}
30
30
 
31
31
  # WebXi header timestamps are 64-bit fixed-point seconds since the Unix epoch
32
32
  # (Q32.32): upper 32 bits = whole seconds, lower 32 bits = fraction of a second.
@@ -65,15 +65,21 @@ def _set_fast_logging_interval(host, enum_value):
65
65
  def _get_slm_utc_offset_seconds(host):
66
66
  """Read the SLM's own configured time zone offset from UTC, in seconds.
67
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
68
+ endpoints = (
69
+ "/WebXi/Applications/SLM/Outputs/TimeZone",
70
+ "/WebXi/Applications/SLM/Setup/RegionalTimeZoneGMT",
71
+ )
72
+ for endpoint in endpoints:
73
+ response = https_requests.get(host, endpoint)
74
+ response.raise_for_status()
75
+ value = str(response.json()).strip()
76
+ match = re.fullmatch(r"(?:GMT|UTC)?([+-])(\d{2}):?(\d{2})", value)
77
+ if match:
78
+ sign, hours, minutes = match.groups()
79
+ if int(hours) < 24 and int(minutes) < 60:
80
+ offset = int(hours) * 3600 + int(minutes) * 60
81
+ return -offset if sign == "-" else offset
82
+ raise ValueError("SLM reported no valid timezone in Outputs/TimeZone or Setup/RegionalTimeZoneGMT")
77
83
 
78
84
 
79
85
  class _TimeStamps:
@@ -176,8 +182,9 @@ class WebXiStreamHandler(stream.StreamHandler):
176
182
  - mode="screen_record": streams the device's ScreenImage sequence, keeping the latest
177
183
  received PNG in `self.image_bytes`.
178
184
 
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.
185
+ time=True additionally subscribes the device's SLMTime sequence and supplies
186
+ ISO timestamps in the SLM's configured time zone. Fast logging uses the device
187
+ header time plus the sample interval for per-sample timestamps.
181
188
  """
182
189
 
183
190
  def __init__(self, host, sequenceID=None, sequenceNames=None,
@@ -220,6 +227,8 @@ class WebXiStreamHandler(stream.StreamHandler):
220
227
  if saving is not None:
221
228
  if saving.lower() not in _SAVING_HANDLERS:
222
229
  raise ValueError(f"Invalid saving format: {saving!r}, must be one of {list(_SAVING_HANDLERS)}")
230
+ if saving.lower() == "wav" and not (self.mp3 or self.flac):
231
+ raise ValueError("WAV saving is only supported for FLAC and MP3 streams")
223
232
  if saving_path is None:
224
233
  saving_path = "stream_data"
225
234
  self._saving_handler = _SAVING_HANDLERS[saving.lower()](saving_path)
@@ -247,6 +256,13 @@ class WebXiStreamHandler(stream.StreamHandler):
247
256
  self.calibrationFactor = (20e-6 * 10 ** (max_lvl / 20)) / (2 ** 23 - 1) * np.sqrt(2)
248
257
 
249
258
  def streamInit(self):
259
+ if (self.mp3 or self.flac) and isinstance(
260
+ self._saving_handler,
261
+ (JsonDataHandler, CsvDataHandler),
262
+ ):
263
+ raise ValueError(
264
+ "FLAC and MP3 streams must be saved as WAV, not JSON or CSV."
265
+ )
250
266
  if self.mp3 or self.flac:
251
267
  quality_setting = "AudioRecordingListenQuality" if self.mp3 else "AudioRecordingAnalysisQuality"
252
268
  response = https_requests.put(self.host, f"/WebXi/Applications/SLM/setup/{quality_setting}", json=1)
@@ -286,17 +302,17 @@ class WebXiStreamHandler(stream.StreamHandler):
286
302
  self.data_type = self.sequence["DataType"]
287
303
 
288
304
  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
305
+ if self.time:
306
+ utc_offset_seconds = _get_slm_utc_offset_seconds(self.host)
307
+ self._slm_timezone = timezone(timedelta(seconds=utc_offset_seconds))
308
+ self._slm_time = None
309
+ time_id, _ = seq.get_sequence(self.host, seq.getSequenceID(self.host, "SLMTime"))
310
+ self._time_ids = {"SLMTime": time_id}
311
+ stream_ids.append(time_id)
312
+ self.stream_ids = stream_ids
290
313
  if self.multi_device:
291
314
  return
292
315
 
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
316
  self.uri = stream.setup_stream(self.host, stream_ids, self.streamName)
301
317
  meas.start_pause_measurement(self.host, True)
302
318
 
@@ -321,13 +337,20 @@ class WebXiStreamHandler(stream.StreamHandler):
321
337
  if package.header.message_type != webxi_stream.WebxiStream.Header.EMessageType.e_sequence_data:
322
338
  return
323
339
  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 ""
340
+ if getattr(block, "sequence_id", None) == self._time_ids["SLMTime"]:
341
+ self._slm_time = int.from_bytes(block.values, "little", signed=True)
342
+
343
+ def _time_prefix(self, epoch_seconds=None):
344
+ if not self.time:
345
+ return ""
346
+ if epoch_seconds is None:
347
+ epoch_seconds = self._slm_time
348
+ if epoch_seconds is None:
349
+ return ""
350
+ timestamp = datetime.fromtimestamp(epoch_seconds, self._slm_timezone)
351
+ if self.fast_logging:
352
+ return timestamp.isoformat(timespec="microseconds")
353
+ return timestamp.strftime("%Y-%m-%dT%H:%M:%S")
331
354
 
332
355
  def _handle_data(self, **data):
333
356
  """Deliver a stream record with the host it originated from."""
@@ -344,9 +367,10 @@ class WebXiStreamHandler(stream.StreamHandler):
344
367
  def _msg_func_cpb(self, message):
345
368
  package = webxi_stream.WebxiStream.from_bytes(message)
346
369
  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)
370
+ for block in package.content.sequence_blocks:
371
+ if block.sequence_id == self.ID:
372
+ self.CPB_values = np.array(stream.data_type_conv(self.sequence["DataType"], block.values, self.sequence["VectorLength"])) / 100
373
+ self._handle_data(timestamp=self._time_prefix(), name=self.sequence["Name"], value=self.CPB_values)
350
374
 
351
375
  def calcFreqBands(self):
352
376
  """Only meaningful when cpb=True."""
@@ -371,11 +395,7 @@ class WebXiStreamHandler(stream.StreamHandler):
371
395
  if package.header.message_type != webxi_stream.WebxiStream.Header.EMessageType.e_sequence_data:
372
396
  return
373
397
 
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
398
  start = _webxi_time_to_epoch(package.header.time)
377
- if abs(start - time.time()) > 86400:
378
- start = time.time()
379
399
 
380
400
  # A 1-second package batches many fast-logged samples.
381
401
  samples = []
@@ -398,8 +418,9 @@ class WebXiStreamHandler(stream.StreamHandler):
398
418
  name = self.sequence.get("Name", "FastLAeq")
399
419
  local = self.sequence.get("LocalName", "LAeq")
400
420
  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)
421
+ for sample_index, value in enumerate(samples):
422
+ timestamp = self._time_prefix(start + sample_index * self.fast_logging_interval_s)
423
+ self._handle_data(timestamp=timestamp, name=name, local=local, value=value, unit=unit)
403
424
 
404
425
  def _msg_func_screen_record(self, message):
405
426
  package = webxi_stream.WebxiStream.from_bytes(message)
@@ -414,16 +435,22 @@ class WebXiStreamHandler(stream.StreamHandler):
414
435
  start = timer()
415
436
  package = webxi_stream.WebxiStream.from_bytes(message)
416
437
  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)
438
+ for block in package.content.sequence_blocks:
439
+ if self.ID not in getattr(block, "sequence_ids", ()):
440
+ continue
441
+ if self.mp3:
442
+ samples, sample_rate = self.tmpFileSM.decode(block.frame)
443
+ if not samples:
444
+ continue
445
+ decoded = (np.asarray(samples, dtype=np.int32) << 8) * self.calibrationFactor
446
+ else:
447
+ decoded = flac2samples.decode(block, self.calibrationFactor)
448
+ sample_rate = 2 ** 16
449
+ DataBuffer.append(decoded)
450
+ self._handle_data(
451
+ samples=decoded, sample_rate=sample_rate,
452
+ full_scale_pa=self.calibrationFactor * (2 ** 23 - 1),
453
+ )
427
454
  total = timer() - start
428
455
  if 0.0625 < total:
429
456
  print(f"TotalTime: {total}")
@@ -1,5 +1,6 @@
1
1
  import enum
2
2
  import tempfile
3
+ import miniaudio
3
4
 
4
5
  # a dual bufferfile stores the data which is used for decoding the mp3 stream.
5
6
  class TmpfileStateMachine:
@@ -15,6 +16,29 @@ class TmpfileStateMachine:
15
16
  self.tempFile1 = None
16
17
  self.tempFile2 = None
17
18
  self.state = self.State.init
19
+ self._decoded_file = None
20
+ self._decoded_frames = 0
21
+
22
+ def decode(self, mp3Data):
23
+ active_file = (
24
+ self.tempFile1
25
+ if self.state in (self.State.init, self.State.file1, self.State.preFile2)
26
+ else self.tempFile2
27
+ )
28
+ if active_file is not self._decoded_file:
29
+ self._decoded_frames = 0
30
+ if active_file is not None:
31
+ active_file.seek(0)
32
+ overlap = active_file.read()
33
+ if overlap:
34
+ self._decoded_frames = miniaudio.mp3_read_s16(overlap).num_frames
35
+ decoded = miniaudio.mp3_read_s16(self.runStateMachine(mp3Data))
36
+ if decoded.nchannels != 1:
37
+ raise ValueError("SLM MP3 audio must be mono")
38
+ samples = decoded.samples[self._decoded_frames:]
39
+ self._decoded_frames = decoded.num_frames
40
+ self._decoded_file = active_file if active_file is not None else self.tempFile1
41
+ return samples, decoded.sample_rate
18
42
 
19
43
  def runStateMachine(self, mp3Data):
20
44
  readData = None
@@ -46,9 +70,10 @@ class TmpfileStateMachine:
46
70
  if self.tempFile2:
47
71
  self.tempFile2.close()
48
72
  self.tempFile2 = None
73
+ self.tempFile1.seek(0, 2)
74
+ self.tempFile1.write(bytes(mp3Data))
49
75
  self.tempFile1.seek(0)
50
76
  dataFromFile = self.tempFile1.read()
51
- self.tempFile1.write(bytes(mp3Data))
52
77
  if (len(dataFromFile) > 20 * 1024):
53
78
  self.state = self.State.preFile2
54
79
  return dataFromFile
@@ -57,12 +82,12 @@ class TmpfileStateMachine:
57
82
  if not self.tempFile2:
58
83
  self.tempFile2 = tempfile.SpooledTemporaryFile(max_size=1 * 1024 * 1024, mode='w+b', suffix=".mp3",
59
84
  prefix="tmp2")
60
- self.tempFile1.seek(0)
61
- self.tempFile2.seek(0)
62
- dataFromFile = self.tempFile1.read()
63
- tmp = self.tempFile2.read()
85
+ self.tempFile1.seek(0, 2)
86
+ self.tempFile2.seek(0, 2)
64
87
  self.tempFile1.write(bytes(mp3Data))
65
88
  self.tempFile2.write(bytes(mp3Data))
89
+ self.tempFile1.seek(0)
90
+ dataFromFile = self.tempFile1.read()
66
91
  if (len(dataFromFile) > 30 * 1024):
67
92
  self.state = self.State.file2
68
93
  return dataFromFile
@@ -71,9 +96,10 @@ class TmpfileStateMachine:
71
96
  if self.tempFile1:
72
97
  self.tempFile1.close()
73
98
  self.tempFile1 = None
99
+ self.tempFile2.seek(0, 2)
100
+ self.tempFile2.write(bytes(mp3Data))
74
101
  self.tempFile2.seek(0)
75
102
  dataFromFile = self.tempFile2.read()
76
- self.tempFile2.write(bytes(mp3Data))
77
103
  if (len(dataFromFile) > 20 * 1024):
78
104
  self.state = self.State.preFile1
79
105
  return dataFromFile
@@ -82,12 +108,12 @@ class TmpfileStateMachine:
82
108
  if not self.tempFile1:
83
109
  self.tempFile1 = tempfile.SpooledTemporaryFile(max_size=1 * 1024 * 1024, mode='w+b', suffix=".mp3",
84
110
  prefix="tmp1")
85
- self.tempFile1.seek(0)
86
- self.tempFile2.seek(0)
87
- dataFromFile = self.tempFile2.read()
88
- tmp = self.tempFile1.read()
111
+ self.tempFile1.seek(0, 2)
112
+ self.tempFile2.seek(0, 2)
89
113
  self.tempFile1.write(bytes(mp3Data))
90
114
  self.tempFile2.write(bytes(mp3Data))
115
+ self.tempFile2.seek(0)
116
+ dataFromFile = self.tempFile2.read()
91
117
  if (len(dataFromFile) > 30 * 1024):
92
118
  self.state = self.State.file1
93
119
  return dataFromFile
@@ -22,6 +22,8 @@ def turn_on_bb_freq_weight(host, weights, fast=False):
22
22
  for weight in weights:
23
23
  put(host , f"/webxi/Applications/SLM/Setup/BBFreqWeight{weight}", json=True)
24
24
  else:
25
+ for weight in weights:
26
+ put(host , f"/webxi/Applications/SLM/Setup/BBFreqWeight{weight}", json=True)
25
27
  for weight in weights:
26
28
  put(host , f"/webxi/Applications/SLM/Setup/FastBBFreqWeight{weight}", json=True)
27
29
 
@@ -29,6 +29,7 @@ from slm_api.helpers.FigureHandler import FigureHandler
29
29
  from slm_api.helpers import TmpfileStateMachine
30
30
  from slm_api.helpers import webxi_helper_functions as webxi_helper
31
31
  from slm_api.helpers.data_handler import CsvDataHandler, MultiDataHandler, DataHandler
32
+ import datetime
32
33
 
33
34
  host = webxi_helper.set_host(__file__)
34
35
  sequenceID = 156
@@ -154,9 +155,11 @@ def on_close():
154
155
 
155
156
 
156
157
  if __name__ == "__main__":
158
+
159
+ filename = f'name-of-file_{datetime.datetime.now().strftime("%H%M_%m%d%Y")}'
160
+
157
161
  # streamer = streamHandler()
158
- streamer = sh.WebXiStreamHandler(host, sequenceID, mode="mp3")
159
- # streamer.setDataHandler(CsvDataHandler(r"C:\Users\SKREBSVINGAA\csv_test\csv_test.txt"))
162
+ streamer = sh.WebXiStreamHandler(host, sequenceID, mode="mp3", saving="wav", saving_path=f"./saved_data/mp3_{filename}")
160
163
  # streamer.setDataHandler(PrintDataHandler())
161
164
 
162
165
  fig = figureHandler()
@@ -0,0 +1,163 @@
1
+ """Plot saved JSON/CSV measurements without connecting to an SLM.
2
+
3
+ Usage: uv run src/slm_api/plot_saved_data_example.py saved_data/measurements.json
4
+ Add --output-dir saved_data/plots to save PNGs instead of opening plot windows.
5
+ CPB bars energy-average equally weighted saved readings, not measurement durations.
6
+ Band numbers are used because the saved files do not include centre frequencies.
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ import matplotlib.dates as mdates
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+
20
+ def load_records(path):
21
+ path = Path(path)
22
+ if path.suffix.lower() == ".csv":
23
+ frame = pd.read_csv(path, encoding="utf-8-sig", dtype=str, keep_default_na=False)
24
+ return frame.to_dict(orient="records")
25
+ if path.suffix.lower() != ".json":
26
+ raise ValueError("Select a .json or .csv measurement file")
27
+ frame = pd.read_json(path, encoding="utf-8-sig", orient="records", convert_dates=False, dtype=False)
28
+ frame = frame.astype(object).where(pd.notna(frame), None)
29
+ records = []
30
+ for entry in frame.to_dict(orient="records"):
31
+ entry = {key: value for key, value in entry.items() if value is not None}
32
+ if "data" in entry:
33
+ if not isinstance(entry["data"], list):
34
+ raise ValueError("Each host's data must be a list")
35
+ for row in entry["data"]:
36
+ if not isinstance(row, dict):
37
+ raise ValueError("Each measurement row must be an object")
38
+ records.append({**row, "host": entry.get("host", "Unknown")})
39
+ else:
40
+ records.append(entry)
41
+ return records
42
+
43
+
44
+ def measurement_value(value, name):
45
+ if isinstance(value, str):
46
+ if not value.strip():
47
+ return None
48
+ try:
49
+ value = json.loads(value)
50
+ except ValueError:
51
+ if name.upper().startswith("CPB"):
52
+ raise ValueError(f"{name} must contain a numeric JSON array, not array display text") from None
53
+ return None
54
+ if isinstance(value, bool) or value is None:
55
+ return None
56
+ if not isinstance(value, (int, float, list)):
57
+ return None
58
+ values = np.asarray(value, dtype=float)
59
+ if values.ndim > 1:
60
+ raise ValueError(f"{name} must be a scalar or a one-dimensional band array")
61
+ return values
62
+
63
+
64
+ def plot_records(records):
65
+ hosts = {}
66
+ for record in records:
67
+ hosts.setdefault(record.get("host") or "Unknown", []).append(record)
68
+ figures = []
69
+ metadata = {"host", "timestamp", "name", "local", "unit", "count", "min", "max"}
70
+ for host, rows in hosts.items():
71
+ broadband = {}
72
+ spectra = {}
73
+ for row_index, row in enumerate(rows):
74
+ measurements = (
75
+ {row["name"]: row["value"]}
76
+ if "name" in row and "value" in row else row
77
+ )
78
+ for name, value in measurements.items():
79
+ if name in metadata:
80
+ continue
81
+ values = measurement_value(value, name)
82
+ if values is None or values.size == 0:
83
+ continue
84
+ if values.ndim == 1:
85
+ spectra.setdefault(name, []).append(values)
86
+ elif np.isfinite(values):
87
+ broadband.setdefault(name, []).append((row_index, float(values)))
88
+
89
+ if broadband:
90
+ timed = all(row.get("timestamp") for row in rows)
91
+ if timed:
92
+ try:
93
+ positions = [datetime.fromisoformat(row["timestamp"].strip()) for row in rows]
94
+ offsets = {position.utcoffset() for position in positions}
95
+ if len(offsets) != 1:
96
+ raise ValueError("Inconsistent timezone offsets")
97
+ except ValueError:
98
+ timed = False
99
+ if not timed:
100
+ positions = list(range(1, len(rows) + 1))
101
+ figure, axis = plt.subplots(figsize=(12, 5), layout="constrained")
102
+ for name, points in broadband.items():
103
+ indices, levels = zip(*points)
104
+ axis.plot([positions[index] for index in indices], levels, label=name)
105
+ if timed:
106
+ locator = mdates.AutoDateLocator()
107
+ axis.xaxis.set_major_locator(locator)
108
+ axis.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
109
+ axis.set(title=f"{host} - Broadband", xlabel="Time" if timed else "Record number", ylabel="Level [dB SPL]")
110
+ axis.grid(alpha=0.3)
111
+ axis.legend()
112
+ figures.append(figure)
113
+
114
+ for name, readings in spectra.items():
115
+ band_count = len(readings[0])
116
+ if any(len(reading) != band_count for reading in readings):
117
+ raise ValueError(f"{host}: {name} changes band count within the file")
118
+ matrix = np.stack(readings)
119
+ bands = [column[np.isfinite(column)] for column in matrix.T]
120
+ if not any(band.size for band in bands):
121
+ continue
122
+ levels = []
123
+ for band in bands:
124
+ if band.size:
125
+ peak = band.max()
126
+ levels.append(peak + 10 * np.log10(np.mean(10 ** ((band - peak) / 10))))
127
+ else:
128
+ levels.append(np.nan)
129
+ figure, axis = plt.subplots(figsize=(12, 5), layout="constrained")
130
+ positions = np.arange(1, band_count + 1)
131
+ axis.bar(positions, levels, width=0.9)
132
+ axis.set_xticks(positions)
133
+ axis.set(title=f"{host} - {name} (energy average of {len(readings)} readings)", xlabel="Frequency band number", ylabel="Level [dB SPL]")
134
+ axis.grid(axis="y", alpha=0.3)
135
+ axis.tick_params(axis="x", labelsize=8)
136
+ figures.append(figure)
137
+ if not figures:
138
+ raise ValueError("No numeric measurement data found in the file")
139
+ return figures
140
+
141
+
142
+ def main():
143
+ parser = argparse.ArgumentParser(description="Plot broadband levels as lines and CPB bands as energy-averaged bars")
144
+ parser.add_argument("path", type=Path, help="Saved JSON or CSV measurement file")
145
+ parser.add_argument("--output-dir", type=Path, help="Save PNG plots instead of opening windows")
146
+ arguments = parser.parse_args()
147
+ try:
148
+ figures = plot_records(load_records(arguments.path))
149
+ except (OSError, ValueError) as error:
150
+ parser.error(str(error))
151
+ if arguments.output_dir:
152
+ arguments.output_dir.mkdir(parents=True, exist_ok=True)
153
+ for index, figure in enumerate(figures, start=1):
154
+ output = arguments.output_dir / f"{arguments.path.stem}_plot_{index}.png"
155
+ figure.savefig(output, dpi=150)
156
+ print(output)
157
+ plt.close(figure)
158
+ else:
159
+ plt.show()
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()
@@ -1,94 +0,0 @@
1
- """Optional DataHandler for WebXiStreamHandler. By default WebXiStreamHandler
2
- does nothing with decoded values - call streamer.setDataHandler(...) with an
3
- instance of one of these (or your own subclass) before startStream() to
4
- receive/print/save them."""
5
-
6
- import csv
7
- import json
8
- import pickle
9
- from pathlib import Path
10
-
11
-
12
- class DataHandler:
13
- """Override handle() to decide what happens with decoded stream values.
14
- """
15
-
16
-
17
- def handle(self, **data):
18
- pass
19
-
20
-
21
- class _BufferedFileDataHandler(DataHandler):
22
- """Base class for handlers that buffer records in memory and write them
23
- all to disk in close(). Call streamer.setDataHandler(...) before startStream(),
24
- then handler.close() (or MultiDataHandler.close()) when you stop the stream."""
25
-
26
- _extension = None # set by subclasses, e.g. "json", "csv", "pkl"
27
-
28
- def __init__(self, path):
29
- path = Path(path)
30
- if self._extension and path.suffix.lower() != f".{self._extension}":
31
- path = path.with_name(path.name + f".{self._extension}")
32
- path.parent.mkdir(parents=True, exist_ok=True)
33
- self._path = path
34
- self._records = []
35
-
36
- def handle(self, **data):
37
- if "values" in data:
38
- data["values"] = [float(v) for v in data["values"]]
39
- elif "samples" in data:
40
- samples = data.pop("samples")
41
- data.update(count=len(samples), min=float(min(samples)), max=float(max(samples)))
42
- self._records.append(data)
43
-
44
- def close(self):
45
- raise NotImplementedError
46
-
47
-
48
- class JsonDataHandler(_BufferedFileDataHandler):
49
- """Writes all buffered records to a single JSON file on close()."""
50
- _extension = "json"
51
-
52
- def close(self):
53
- with open(self._path, "w") as f:
54
- json.dump(self._records, f, indent=2, default=str)
55
-
56
-
57
- class CsvDataHandler(_BufferedFileDataHandler):
58
- """Writes all buffered records to a CSV file on close(). Columns vary per
59
- row since different modes carry different fields."""
60
- _extension = "csv"
61
-
62
- def close(self):
63
- fieldnames = list(dict.fromkeys(key for record in self._records for key in record))
64
- with open(self._path, "w", newline="") as f:
65
- writer = csv.DictWriter(f, fieldnames=fieldnames)
66
- writer.writeheader()
67
- writer.writerows(self._records)
68
-
69
-
70
- class PickleDataHandler(_BufferedFileDataHandler):
71
- """Writes all buffered records (as Python objects) to a pickle file on close()."""
72
- _extension = "pkl"
73
-
74
- def close(self):
75
- with open(self._path, "wb") as f:
76
- pickle.dump(self._records, f)
77
-
78
-
79
-
80
-
81
- class MultiDataHandler(DataHandler):
82
- """Fans out to several handlers at once (e.g. print AND save AND plot)."""
83
-
84
- def __init__(self, *handlers):
85
- self._handlers = handlers
86
-
87
- def handle(self, **data):
88
- for h in self._handlers:
89
- h.handle(**data)
90
-
91
- def close(self):
92
- for h in self._handlers:
93
- if hasattr(h, "close"):
94
- h.close()