slm_open_api_package 0.2.10__tar.gz → 0.2.12__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 (40) hide show
  1. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/PKG-INFO +17 -4
  2. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/README.md +14 -3
  3. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/pyproject.toml +3 -1
  4. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/pyproject.toml.orig +3 -1
  5. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/CPB_example.py +10 -5
  6. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/example_multi.py +6 -2
  7. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/fast_log_example.py +19 -4
  8. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/flac_example.py +4 -1
  9. slm_open_api_package-0.2.12/src/slm_api/helpers/data_handler.py +251 -0
  10. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/stream_handlers.py +80 -52
  11. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/tmpFileStateMachine.py +36 -10
  12. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/webxi_helper_functions.py +2 -0
  13. slm_open_api_package-0.2.12/src/slm_api/leq.py +73 -0
  14. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/mp3_example.py +5 -2
  15. slm_open_api_package-0.2.12/src/slm_api/plot_saved_data_example.py +163 -0
  16. slm_open_api_package-0.2.10/src/slm_api/helpers/data_handler.py +0 -94
  17. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/__init__.py +0 -0
  18. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/enums/fast_log_intervals_enum.py +0 -0
  19. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/enums/sequence_id_enum.py +0 -0
  20. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/FigureHandler.py +0 -0
  21. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/Leq.py +0 -0
  22. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/__init__.py +0 -0
  23. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/buffer.py +0 -0
  24. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/fft.py +0 -0
  25. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/flac_stream_2_samples.py +0 -0
  26. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/https_requests.py +0 -0
  27. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/measurment_handler.py +0 -0
  28. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/sequence_handler.py +0 -0
  29. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/stream_handler.py +0 -0
  30. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/websocket_handler.py +0 -0
  31. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/helpers/webxi_root.pem +0 -0
  32. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/multi_device_flac.py +0 -0
  33. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/multi_instrument_example.py +0 -0
  34. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/screen_record_example.py +0 -0
  35. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/webxi/README.md +0 -0
  36. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/webxi/__init__.py +0 -0
  37. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/webxi/webxi-header.ksy +0 -0
  38. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/webxi/webxi-stream.ksy +0 -0
  39. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/src/slm_api/webxi/webxi_header.py +0 -0
  40. {slm_open_api_package-0.2.10 → slm_open_api_package-0.2.12}/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.12
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.12"
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.12"
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()