recs 0.2.0__tar.gz → 0.3.0__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 (59) hide show
  1. {recs-0.2.0 → recs-0.3.0}/PKG-INFO +59 -17
  2. {recs-0.2.0 → recs-0.3.0}/README.md +53 -12
  3. {recs-0.2.0 → recs-0.3.0}/pyproject.toml +12 -8
  4. recs-0.3.0/recs/__main__.py +29 -0
  5. {recs-0.2.0 → recs-0.3.0}/recs/audio/block.py +2 -5
  6. recs-0.3.0/recs/audio/channel_writer.py +194 -0
  7. {recs-0.2.0 → recs-0.3.0}/recs/audio/file_opener.py +14 -15
  8. recs-0.3.0/recs/base/_query_device.py +21 -0
  9. {recs-0.2.0 → recs-0.3.0}/recs/base/cfg_raw.py +16 -14
  10. recs-0.3.0/recs/base/prefix_dict.py +26 -0
  11. recs-0.3.0/recs/base/state.py +73 -0
  12. recs-0.3.0/recs/base/times.py +50 -0
  13. {recs-0.2.0 → recs-0.3.0}/recs/base/type_conversions.py +5 -5
  14. {recs-0.2.0 → recs-0.3.0}/recs/base/types.py +2 -7
  15. recs-0.3.0/recs/cfg/__init__.py +15 -0
  16. {recs-0.2.0 → recs-0.3.0}/recs/cfg/aliases.py +32 -21
  17. recs-0.3.0/recs/cfg/app.py +87 -0
  18. recs-0.3.0/recs/cfg/cfg.py +83 -0
  19. recs-0.3.0/recs/cfg/cli.py +254 -0
  20. recs-0.3.0/recs/cfg/device.py +99 -0
  21. {recs-0.2.0/recs/base → recs-0.3.0/recs/cfg}/metadata.py +23 -2
  22. recs-0.3.0/recs/cfg/path_pattern.py +156 -0
  23. recs-0.3.0/recs/cfg/run_cli.py +43 -0
  24. recs-0.2.0/recs/base/times.py → recs-0.3.0/recs/cfg/time_settings.py +3 -51
  25. {recs-0.2.0 → recs-0.3.0}/recs/cfg/track.py +4 -4
  26. recs-0.3.0/recs/misc/contexts.py +10 -0
  27. {recs-0.2.0 → recs-0.3.0}/recs/misc/counter.py +9 -8
  28. {recs-0.2.0 → recs-0.3.0}/recs/misc/legal_filename.py +1 -1
  29. recs-0.3.0/recs/misc/log.py +43 -0
  30. recs-0.3.0/recs/ui/__init__.py +0 -0
  31. recs-0.3.0/recs/ui/device_process.py +37 -0
  32. recs-0.3.0/recs/ui/device_recorder.py +83 -0
  33. {recs-0.2.0 → recs-0.3.0}/recs/ui/device_tracks.py +9 -1
  34. recs-0.3.0/recs/ui/full_state.py +50 -0
  35. {recs-0.2.0 → recs-0.3.0}/recs/ui/live.py +28 -16
  36. recs-0.3.0/recs/ui/recorder.py +74 -0
  37. {recs-0.2.0 → recs-0.3.0}/recs/ui/table.py +1 -1
  38. recs-0.2.0/recs/__main__.py +0 -6
  39. recs-0.2.0/recs/audio/channel_writer.py +0 -178
  40. recs-0.2.0/recs/base/prefix_dict.py +0 -27
  41. recs-0.2.0/recs/cfg/__init__.py +0 -6
  42. recs-0.2.0/recs/cfg/cfg.py +0 -107
  43. recs-0.2.0/recs/cfg/cli.py +0 -193
  44. recs-0.2.0/recs/cfg/device.py +0 -105
  45. recs-0.2.0/recs/cfg/run.py +0 -32
  46. recs-0.2.0/recs/misc/recording_path.py +0 -47
  47. recs-0.2.0/recs/ui/__init__.py +0 -1
  48. recs-0.2.0/recs/ui/channel_recorder.py +0 -51
  49. recs-0.2.0/recs/ui/device_recorder.py +0 -99
  50. recs-0.2.0/recs/ui/recorder.py +0 -93
  51. {recs-0.2.0 → recs-0.3.0}/LICENSE +0 -0
  52. {recs-0.2.0 → recs-0.3.0}/recs/__init__.py +0 -0
  53. {recs-0.2.0 → recs-0.3.0}/recs/audio/__init__.py +0 -0
  54. {recs-0.2.0 → recs-0.3.0}/recs/audio/header_size.py +0 -0
  55. {recs-0.2.0 → recs-0.3.0}/recs/base/__init__.py +0 -0
  56. {recs-0.2.0 → recs-0.3.0}/recs/base/pyproject.py +0 -0
  57. {recs-0.2.0/recs/misc → recs-0.3.0/recs/cfg}/hash_cmp.py +0 -0
  58. {recs-0.2.0 → recs-0.3.0}/recs/misc/__init__.py +0 -0
  59. {recs-0.2.0 → recs-0.3.0}/recs/misc/file_list.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: recs
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: 🎙 recs: the Universal Recorder 🎙
5
5
  License: MIT
6
6
  Author: Tom Ritchford
@@ -10,9 +10,10 @@ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.10
12
12
  Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
13
14
  Requires-Dist: coverage
14
15
  Requires-Dist: dtyper
15
- Requires-Dist: humanfriendly (>=10.0,<11.0)
16
+ Requires-Dist: humanfriendly
16
17
  Requires-Dist: impall
17
18
  Requires-Dist: numpy
18
19
  Requires-Dist: overrides
@@ -20,9 +21,9 @@ Requires-Dist: pyaudio
20
21
  Requires-Dist: rich
21
22
  Requires-Dist: sounddevice
22
23
  Requires-Dist: soundfile
23
- Requires-Dist: strenum (>=0.4.15,<0.5.0)
24
- Requires-Dist: threa
25
- Requires-Dist: tomli (>=2.0.1,<3.0.0)
24
+ Requires-Dist: strenum
25
+ Requires-Dist: threa (>=1.9.0)
26
+ Requires-Dist: tomli
26
27
  Requires-Dist: typer
27
28
  Description-Content-Type: text/markdown
28
29
 
@@ -60,13 +61,13 @@ has been used to mitigate and minimize this through software. See Appendix A.
60
61
 
61
62
  ### Universal?
62
63
 
63
- It is a "Universal Recorder" because I plan to be able to record all streams of data in
64
- some coherent way.
64
+ It is a "Universal Recorder" because the plan to be able to record all streams of data:
65
+ audio is simply the start.
65
66
 
66
- I have already [written code](https://github.com/rec/litoid) to do this for
67
- MIDI and DMX and I'll be folding that in in due time, but most of the difficulty
68
- and most of the value in this first step is the audio, so I have focused on just audio
69
- for this first release!
67
+ I have already [written code](https://github.com/rec/litoid) to do this for MIDI and DMX
68
+ - it works well but it isn't productionized, and I'll be folding that in in due time,
69
+ but most of the difficulty and most of the value in this first step is the audio, so I
70
+ have focused on just audio for this first release!
70
71
 
71
72
  It might be that video is also incorporated in the far future, but the tooling is just
72
73
  not there for Python yet, and it would be much too heavy to sit in the background all
@@ -75,12 +76,53 @@ Recorder if you liked.
75
76
 
76
77
  ### Installation
77
78
 
78
- Use
79
-
80
- ### Usage
81
-
82
-
83
-
79
+ `recs` is a standard PyPi package - use `poetry add recs` or `pip install recs` or your
80
+ favorite package manager.
81
+
82
+ To test, type `recs --info`, which prints JSON describing the input devices
83
+ you have. Here's a snippet from my machine:
84
+
85
+ ```
86
+ [
87
+ {
88
+ "name": "FLOW 8 (Recording)",
89
+ "index": 1,
90
+ "hostapi": 0,
91
+ "max_input_channels": 10,
92
+ "max_output_channels": 4,
93
+ "default_low_input_latency": 0.01,
94
+ "default_low_output_latency": 0.004354166666666667,
95
+ "default_high_input_latency": 0.1,
96
+ "default_high_output_latency": 0.0136875,
97
+ "default_samplerate": 48000.0
98
+ },
99
+ {
100
+ "name": "USB PnP Sound Device",
101
+ "index": 2,
102
+ ...
103
+ },
104
+ ...
105
+ ]
106
+ ```
107
+
108
+ ### Basic Usage
109
+
110
+ Pick your nicest terminal program, go to a favorite directory with some free space, and
111
+ type:
112
+
113
+ ```
114
+ recs
115
+ ```
116
+
117
+ `recs` will start recording all the active audio channels into your current directory
118
+ and display the results in the terminal.
119
+
120
+ What "active"means can be customized rather a lot, but by default when a channel becomes
121
+ too quiet for more than a short time, it stops recording, and will start a new recording
122
+ automatically when the channel receives a signal.
123
+
124
+ Some care is taken to preserve the quiet before the start or after the end of a
125
+ recording to prevent abrupt transitions.
84
126
 
85
127
 
86
128
  #### Appendix A: Failure modes
@@ -32,13 +32,13 @@ has been used to mitigate and minimize this through software. See Appendix A.
32
32
 
33
33
  ### Universal?
34
34
 
35
- It is a "Universal Recorder" because I plan to be able to record all streams of data in
36
- some coherent way.
35
+ It is a "Universal Recorder" because the plan to be able to record all streams of data:
36
+ audio is simply the start.
37
37
 
38
- I have already [written code](https://github.com/rec/litoid) to do this for
39
- MIDI and DMX and I'll be folding that in in due time, but most of the difficulty
40
- and most of the value in this first step is the audio, so I have focused on just audio
41
- for this first release!
38
+ I have already [written code](https://github.com/rec/litoid) to do this for MIDI and DMX
39
+ - it works well but it isn't productionized, and I'll be folding that in in due time,
40
+ but most of the difficulty and most of the value in this first step is the audio, so I
41
+ have focused on just audio for this first release!
42
42
 
43
43
  It might be that video is also incorporated in the far future, but the tooling is just
44
44
  not there for Python yet, and it would be much too heavy to sit in the background all
@@ -47,12 +47,53 @@ Recorder if you liked.
47
47
 
48
48
  ### Installation
49
49
 
50
- Use
51
-
52
- ### Usage
53
-
54
-
55
-
50
+ `recs` is a standard PyPi package - use `poetry add recs` or `pip install recs` or your
51
+ favorite package manager.
52
+
53
+ To test, type `recs --info`, which prints JSON describing the input devices
54
+ you have. Here's a snippet from my machine:
55
+
56
+ ```
57
+ [
58
+ {
59
+ "name": "FLOW 8 (Recording)",
60
+ "index": 1,
61
+ "hostapi": 0,
62
+ "max_input_channels": 10,
63
+ "max_output_channels": 4,
64
+ "default_low_input_latency": 0.01,
65
+ "default_low_output_latency": 0.004354166666666667,
66
+ "default_high_input_latency": 0.1,
67
+ "default_high_output_latency": 0.0136875,
68
+ "default_samplerate": 48000.0
69
+ },
70
+ {
71
+ "name": "USB PnP Sound Device",
72
+ "index": 2,
73
+ ...
74
+ },
75
+ ...
76
+ ]
77
+ ```
78
+
79
+ ### Basic Usage
80
+
81
+ Pick your nicest terminal program, go to a favorite directory with some free space, and
82
+ type:
83
+
84
+ ```
85
+ recs
86
+ ```
87
+
88
+ `recs` will start recording all the active audio channels into your current directory
89
+ and display the results in the terminal.
90
+
91
+ What "active"means can be customized rather a lot, but by default when a channel becomes
92
+ too quiet for more than a short time, it stops recording, and will start a new recording
93
+ automatically when the channel receives a signal.
94
+
95
+ Some care is taken to preserve the quiet before the start or after the end of a
96
+ recording to prevent abrupt transitions.
56
97
 
57
98
 
58
99
  #### Appendix A: Failure modes
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "recs"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "🎙 recs: the Universal Recorder 🎙"
5
5
  authors = ["Tom Ritchford <tom@swirly.com>"]
6
6
  license = "MIT"
@@ -14,14 +14,14 @@ pyaudio = "*"
14
14
  rich = "*"
15
15
  sounddevice = "*"
16
16
  soundfile = "*"
17
- threa = "*"
17
+ threa = ">=1.9.0"
18
18
  typer = "*"
19
19
  impall = "*"
20
20
  overrides = "*"
21
21
  coverage = "*"
22
- strenum = "^0.4.15"
23
- humanfriendly = "^10.0"
24
- tomli = "^2.0.1"
22
+ strenum = "*"
23
+ humanfriendly = "*"
24
+ tomli = "*"
25
25
 
26
26
  [tool.poetry.group.dev.dependencies]
27
27
  black = "*"
@@ -39,10 +39,14 @@ build-backend = "poetry.core.masonry.api"
39
39
  [tool.coverage.run]
40
40
  branch = true
41
41
  source = ["recs"]
42
- omit = ["recs/cfg/cli.py"]
42
+ omit = [
43
+ "recs/__main__.py",
44
+ "recs/cfg/cli.py",
45
+ "recs/cfg/app.py",
46
+ ]
43
47
 
44
48
  [tool.coverage.report]
45
- fail_under = 97
49
+ fail_under = 94
46
50
  skip_covered = true
47
51
  exclude_lines = [
48
52
  "pragma: no cover",
@@ -67,7 +71,7 @@ warn_unused_ignores = true
67
71
 
68
72
  [tool.black]
69
73
  skip-string-normalization = true
70
- target-version = ["py311"]
74
+ target-version = ["py310"]
71
75
 
72
76
  [tool.isort]
73
77
  profile = "black"
@@ -0,0 +1,29 @@
1
+ import sys
2
+
3
+ import click
4
+
5
+ from recs.base import RecsError
6
+ from recs.cfg import app, cli
7
+
8
+ assert cli
9
+
10
+
11
+ def run() -> int:
12
+ try:
13
+ app.app(prog_name='recs', standalone_mode=False)
14
+ return 0
15
+
16
+ except RecsError as e:
17
+ print('ERROR:', *e.args, file=sys.stderr)
18
+
19
+ except click.ClickException as e:
20
+ print(f'{e.__class__.__name__}: {e.message}', file=sys.stderr)
21
+
22
+ except click.Abort:
23
+ print('Interrupted', file=sys.stderr)
24
+
25
+ return -1
26
+
27
+
28
+ if __name__ == '__main__':
29
+ sys.exit(run())
@@ -37,7 +37,7 @@ class Block:
37
37
 
38
38
  @cached_property
39
39
  def volume(self) -> float:
40
- return sum(self.amplitude) / len(self.amplitude) / self.scale
40
+ return sum(self.amplitude) / len(self.amplitude)
41
41
 
42
42
  @cached_property
43
43
  def channel_count(self) -> int:
@@ -45,7 +45,7 @@ class Block:
45
45
 
46
46
  @cached_property
47
47
  def amplitude(self) -> np.ndarray:
48
- return (self.max - self.min) / 2
48
+ return (self.max - self.min) / (2 * self.scale)
49
49
 
50
50
  @cached_property
51
51
  def max(self) -> np.ndarray:
@@ -99,6 +99,3 @@ class Blocks:
99
99
 
100
100
  def __getitem__(self, i: int) -> Block:
101
101
  return self.blocks[i]
102
-
103
- def __len__(self) -> int:
104
- return len(self.blocks)
@@ -0,0 +1,194 @@
1
+ import contextlib
2
+ import typing as t
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from threading import Lock
6
+
7
+ from overrides import override
8
+ from soundfile import SoundFile
9
+ from threa import Runnable
10
+
11
+ from recs.base.state import ChannelState
12
+ from recs.base.types import SDTYPE, Active, Format, SdType
13
+ from recs.cfg import Cfg, Track, device, time_settings
14
+ from recs.misc import counter, file_list
15
+
16
+ from .block import Block, Blocks
17
+ from .file_opener import FileOpener
18
+ from .header_size import header_size
19
+
20
+ URL = 'https://github.com/rec/recs'
21
+
22
+ BUFFER = 128
23
+ FORMAT_TO_SIZE_LIMIT = {
24
+ Format.aiff: 0x8000_0000,
25
+ Format.wav: 0x1_0000_0000,
26
+ }
27
+
28
+ ITEMSIZE = {
29
+ SdType.float32: 4,
30
+ SdType.int16: 2,
31
+ SdType.int32: 4,
32
+ }
33
+
34
+ BLOCK_FUZZ = 2
35
+
36
+
37
+ class ChannelWriter(Runnable):
38
+ bytes_in_this_file: int = 0
39
+
40
+ frames_in_this_file: int = 0
41
+ frames_written: int = 0 # Used elsewhere
42
+
43
+ largest_file_size: int = 0
44
+ longest_file_frames: int = 0
45
+
46
+ timestamp: float = 0
47
+
48
+ _sf: SoundFile | None = None
49
+
50
+ @property
51
+ def active(self) -> Active:
52
+ return Active.active if self._sf else Active.inactive
53
+
54
+ def __init__(
55
+ self, cfg: Cfg, times: time_settings.TimeSettings[int], track: Track
56
+ ) -> None:
57
+ super().__init__()
58
+
59
+ self.do_not_record = cfg.dry_run or cfg.calibrate
60
+ self.format = cfg.format
61
+ self.metadata = cfg.metadata
62
+ self.times = times
63
+ self.track = track
64
+
65
+ self._blocks = Blocks()
66
+ self._lock = Lock()
67
+
68
+ self.files_written = file_list.FileList()
69
+ self.frame_size = ITEMSIZE[cfg.sdtype or SDTYPE] * len(track.channels)
70
+ self.longest_file_frames = times.longest_file_time
71
+ self.opener = FileOpener(cfg, track)
72
+ self._volume = counter.MovingBlock(times.moving_average_time)
73
+
74
+ if not cfg.infinite_length:
75
+ largest = FORMAT_TO_SIZE_LIMIT.get(cfg.format, 0)
76
+ self.largest_file_size = max(largest - BUFFER, 0)
77
+
78
+ def update(self, update: device.Update) -> ChannelState:
79
+ block = Block(update.array[:, self.track.slice])
80
+ with self._lock:
81
+ return self._receive_block(block, update.timestamp)
82
+
83
+ @override
84
+ def stop(self) -> None:
85
+ with self._lock:
86
+ self.running = False
87
+ self._write_and_close()
88
+ self.stopped = True
89
+
90
+ def _close(self) -> None:
91
+ sf, self._sf = self._sf, None
92
+ if sf and sf.frames and sf.frames >= self.times.shortest_file_time:
93
+ sf.close()
94
+ elif sf:
95
+ with contextlib.suppress(Exception):
96
+ sf.close()
97
+ with contextlib.suppress(Exception):
98
+ Path(sf.name).unlink()
99
+
100
+ def _open(self, offset: int) -> SoundFile:
101
+ timestamp = self.timestamp - offset / self.track.device.samplerate
102
+ ts = datetime.fromtimestamp(timestamp)
103
+
104
+ index = 1 + len(self.files_written)
105
+
106
+ metadata = {'date': ts.isoformat(), 'software': URL, 'tracknumber': str(index)}
107
+ metadata |= self.metadata
108
+
109
+ self.bytes_in_this_file = header_size(metadata, self.format)
110
+ self.frames_in_this_file = 0
111
+
112
+ sf = self.opener.create(metadata, timestamp, index)
113
+ self.files_written.append(Path(sf.name))
114
+ return sf
115
+
116
+ def _receive_block(self, block: Block, timestamp: float) -> ChannelState:
117
+ saved_state = self._state(
118
+ max_amp=max(block.max) / block.scale,
119
+ min_amp=min(block.min) / block.scale,
120
+ )
121
+
122
+ dt = self.timestamp - timestamp
123
+ self.timestamp = timestamp
124
+ self._volume(block)
125
+
126
+ if not self.do_not_record and (self._sf or not self.stopped):
127
+ expected_dt = len(block) / self.track.device.samplerate
128
+
129
+ if dt > expected_dt * BLOCK_FUZZ: # We were asleep, or otherwise lost time
130
+ self._write_and_close()
131
+
132
+ self._blocks.append(block)
133
+
134
+ if block.volume >= self.times.noise_floor_amplitude:
135
+ if not self._sf: # Record some quiet before the first block
136
+ length = self.times.quiet_before_start + len(self._blocks[-1])
137
+ self._blocks.clip(length, from_start=True)
138
+
139
+ self._write_blocks(self._blocks)
140
+ self._blocks.clear()
141
+
142
+ if self.stopped or self._blocks.duration > self.times.stop_after_quiet:
143
+ self._write_and_close()
144
+
145
+ return self._state() - saved_state
146
+
147
+ def _state(self, **kwargs) -> ChannelState:
148
+ return ChannelState(
149
+ file_count=len(self.files_written),
150
+ file_size=self.files_written.total_size,
151
+ is_active=bool(self._sf),
152
+ recorded_time=self.frames_written / self.track.device.samplerate,
153
+ timestamp=self.timestamp,
154
+ volume=tuple(self._volume.mean()),
155
+ **kwargs,
156
+ )
157
+
158
+ def _write_and_close(self) -> None:
159
+ # Record some quiet after the last block
160
+ removed = self._blocks.clip(self.times.quiet_after_end, from_start=False)
161
+
162
+ if self._sf and removed:
163
+ self._write_blocks(reversed(removed))
164
+
165
+ self._close()
166
+
167
+ def _write_blocks(self, blox: t.Iterable[Block]) -> None:
168
+ blocks = list(blox)
169
+
170
+ # The last block in the list ends at self.timestamp so
171
+ # we keep track of the sample offset before that
172
+ offset = -sum(len(b) for b in blocks)
173
+
174
+ for b in blocks:
175
+ # Check if this block will overrun the file size or length
176
+ remains: list[int] = []
177
+
178
+ if self.longest_file_frames:
179
+ remains.append(self.longest_file_frames - self.frames_in_this_file)
180
+
181
+ if self._sf and self.largest_file_size:
182
+ file_bytes = self.largest_file_size - self.bytes_in_this_file
183
+ remains.append(file_bytes // self.frame_size)
184
+
185
+ if remains and min(remains) <= len(b):
186
+ self._close()
187
+
188
+ self._sf = self._sf or self._open(offset)
189
+ self._sf.write(b.block)
190
+ offset += len(b)
191
+
192
+ self.frames_in_this_file += len(b)
193
+ self.frames_written += len(b)
194
+ self.bytes_in_this_file += len(b) * self.frame_size
@@ -1,9 +1,10 @@
1
+ import itertools
1
2
  from pathlib import Path
2
3
 
3
4
  import soundfile as sf
4
5
 
5
6
  from recs.cfg import Cfg, Track
6
- from recs.misc.recording_path import recording_path
7
+ from recs.cfg.metadata import ALLOWS_METADATA
7
8
 
8
9
 
9
10
  class FileOpener:
@@ -27,23 +28,21 @@ class FileOpener:
27
28
  subtype=self.cfg.subtype,
28
29
  )
29
30
 
30
- for k, v in metadata.items():
31
- setattr(fp, k, v)
31
+ if self.cfg.format in ALLOWS_METADATA:
32
+ for k, v in metadata.items():
33
+ setattr(fp, k, v)
32
34
 
33
35
  return fp
34
36
 
35
- def create(self, metadata: dict[str, str], timestamp: float) -> sf.SoundFile:
36
- index = 0
37
- suffix = ''
38
- path, name = recording_path(
39
- self.track, self.cfg.aliases, self.cfg.subdirectory, timestamp
40
- )
37
+ def create(
38
+ self, metadata: dict[str, str], timestamp: float, index: int
39
+ ) -> sf.SoundFile:
40
+ p = Path(self.cfg.path.evaluate(self.track, self.cfg.aliases, timestamp, index))
41
+ p.parent.mkdir(exist_ok=True, parents=True)
41
42
 
42
- while True:
43
- p = self.cfg.path / path / (name + suffix)
44
- p.parent.mkdir(exist_ok=True, parents=True)
43
+ for i in itertools.count():
44
+ f = p.parent / (p.name + bool(i) * f'_{i}')
45
45
  try:
46
- return self.open(p, metadata)
46
+ return self.open(f, metadata)
47
47
  except FileExistsError:
48
- index += 1
49
- suffix = f'_{index}'
48
+ pass
@@ -0,0 +1,21 @@
1
+ """
2
+ Print the current devices as JSON without loading any other part of recs.
3
+
4
+ Called repeatedly as a subprocess to detect devices going off- and online.
5
+ """
6
+
7
+ import json
8
+ import typing as t
9
+
10
+
11
+ def query_devices() -> list[dict[str, t.Any]]:
12
+ try:
13
+ import sounddevice
14
+
15
+ return sounddevice.query_devices()
16
+ except Exception:
17
+ return []
18
+
19
+
20
+ if __name__ == '__main__':
21
+ print(json.dumps(query_devices(), indent=4))
@@ -11,15 +11,15 @@ class CfgRaw:
11
11
  #
12
12
  # Directory settings
13
13
  #
14
- path: Path = Path()
15
- subdirectory: t.Sequence[str] = ()
14
+ path: str = ''
16
15
  #
17
16
  # General purpose settings
18
17
  #
18
+ calibrate: bool = False
19
19
  dry_run: bool = False
20
+ verbose: bool = False
20
21
  info: bool = False
21
22
  list_types: bool = False
22
- verbose: bool = False
23
23
  #
24
24
  # Aliases for input devices or channels
25
25
  #
@@ -40,19 +40,21 @@ class CfgRaw:
40
40
  #
41
41
  # Console and UI settings
42
42
  #
43
+ clear: bool = False
43
44
  silent: bool = False
44
- retain: bool = True
45
- ui_refresh_rate: float = 23
46
- sleep_time: float = 0.013
45
+ sleep_time_device: float = 0.1
46
+ ui_refresh_rate: float = 23.0
47
47
  #
48
48
  # Settings relating to times
49
49
  #
50
50
  infinite_length: bool = False
51
- longest_file_time: str = '0' # In HH:MM:SS.SSSS
52
- moving_average_time: float = 1
53
- noise_floor: float = 70
54
- shortest_file_time: str = '1' # In HH:MM:SS.SSSS
55
- quiet_after_end: float = 2
56
- quiet_before_start: float = 1
57
- stop_after_quiet: float = 20
58
- total_run_time: float = 0
51
+ longest_file_time: float = 0.0
52
+ moving_average_time: float = 1.0
53
+ noise_floor: float = 70.0
54
+ shortest_file_time: float = 1.0
55
+ quiet_after_end: float = 2.0
56
+ quiet_before_start: float = 1.0
57
+ stop_after_quiet: float = 20.0
58
+ total_run_time: float = 0.0
59
+
60
+ asdict = dc.asdict
@@ -0,0 +1,26 @@
1
+ import typing as t
2
+
3
+ T = t.TypeVar('T')
4
+
5
+
6
+ class PrefixDict(dict[str, T]):
7
+ def __getitem__(self, key: str) -> T:
8
+ try:
9
+ return super().__getitem__(key)
10
+ except KeyError:
11
+ pass
12
+
13
+ error = 'unknown'
14
+ if key := key.strip().lower():
15
+ try:
16
+ return super().__getitem__(key)
17
+ except KeyError:
18
+ pass
19
+
20
+ matches = [v for k, v in self.items() if k.lower().startswith(key)]
21
+ if len(matches) == 1:
22
+ return matches[0]
23
+ if matches:
24
+ error = 'ambiguous'
25
+
26
+ raise KeyError(key, error)