recs 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- recs/__main__.py +27 -4
- recs/audio/block.py +2 -5
- recs/audio/channel_writer.py +94 -78
- recs/audio/file_opener.py +14 -15
- recs/base/_query_device.py +21 -0
- recs/base/cfg_raw.py +16 -14
- recs/base/prefix_dict.py +10 -11
- recs/base/state.py +73 -0
- recs/base/times.py +3 -62
- recs/base/type_conversions.py +5 -5
- recs/base/types.py +2 -7
- recs/cfg/__init__.py +11 -2
- recs/cfg/aliases.py +32 -21
- recs/cfg/app.py +87 -0
- recs/cfg/cfg.py +36 -60
- recs/cfg/cli.py +161 -100
- recs/cfg/device.py +37 -43
- recs/{base → cfg}/metadata.py +23 -2
- recs/cfg/path_pattern.py +156 -0
- recs/cfg/run_cli.py +43 -0
- recs/cfg/time_settings.py +61 -0
- recs/cfg/track.py +4 -4
- recs/misc/contexts.py +10 -0
- recs/misc/counter.py +9 -8
- recs/misc/legal_filename.py +1 -1
- recs/misc/log.py +43 -0
- recs/ui/__init__.py +0 -1
- recs/ui/device_process.py +37 -0
- recs/ui/device_recorder.py +66 -82
- recs/ui/device_tracks.py +9 -1
- recs/ui/full_state.py +50 -0
- recs/ui/live.py +28 -16
- recs/ui/recorder.py +52 -71
- recs/ui/table.py +1 -1
- {recs-0.2.0.dist-info → recs-0.3.0.dist-info}/METADATA +59 -17
- recs-0.3.0.dist-info/RECORD +47 -0
- {recs-0.2.0.dist-info → recs-0.3.0.dist-info}/WHEEL +1 -1
- recs/cfg/run.py +0 -32
- recs/misc/recording_path.py +0 -47
- recs/ui/channel_recorder.py +0 -51
- recs-0.2.0.dist-info/RECORD +0 -40
- /recs/{misc → cfg}/hash_cmp.py +0 -0
- {recs-0.2.0.dist-info → recs-0.3.0.dist-info}/LICENSE +0 -0
- {recs-0.2.0.dist-info → recs-0.3.0.dist-info}/entry_points.txt +0 -0
recs/cfg/cli.py
CHANGED
|
@@ -1,37 +1,17 @@
|
|
|
1
1
|
import string
|
|
2
|
-
import sys
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
|
|
5
|
-
import click
|
|
6
4
|
import dtyper
|
|
7
5
|
from typer import Argument, rich_utils
|
|
8
6
|
|
|
9
|
-
from recs.base import
|
|
7
|
+
from recs.base import types
|
|
10
8
|
from recs.base.cfg_raw import CfgRaw
|
|
11
9
|
|
|
12
|
-
from .
|
|
10
|
+
from . import app, cfg
|
|
13
11
|
|
|
14
12
|
rich_utils.STYLE_METAVAR = 'dim yellow'
|
|
15
|
-
INTRO = f"""
|
|
16
|
-
{pyproject.message()}
|
|
17
|
-
|
|
18
|
-
============================================="""
|
|
19
|
-
LINES = (
|
|
20
|
-
INTRO,
|
|
21
|
-
'Why should there be a record button at all?',
|
|
22
|
-
'I wanted to digitize a huge number of cassettes and LPs, so I wanted a '
|
|
23
|
-
+ 'program that ran in the background and recorded everything except quiet.',
|
|
24
|
-
'Nothing like that existed so I wrote it. Free, open-source, configurable.',
|
|
25
|
-
'Full documentation here: https://github.com/rec/recs',
|
|
26
|
-
'',
|
|
27
|
-
)
|
|
28
|
-
HELP = '\n\n\n\n'.join(LINES)
|
|
29
13
|
# Three blank lines seems to force Typer to format correctly
|
|
30
14
|
|
|
31
|
-
app = dtyper.Typer(
|
|
32
|
-
add_completion=False,
|
|
33
|
-
context_settings={'help_option_names': ['--help', '-h']},
|
|
34
|
-
)
|
|
35
15
|
_SINGLES: set[str] = set()
|
|
36
16
|
|
|
37
17
|
RECS = CfgRaw()
|
|
@@ -43,151 +23,232 @@ def Option(default, *a, **ka) -> dtyper.Option:
|
|
|
43
23
|
return dtyper.Option(default, *a, **ka)
|
|
44
24
|
|
|
45
25
|
|
|
46
|
-
|
|
26
|
+
GENERAL_PANEL = 'Options' # 'General Settings'
|
|
27
|
+
SUBCOMMANDS_PANEL = 'Subcommands'
|
|
28
|
+
NAMES_PANEL = 'Selecting and Naming Devices and Channels'
|
|
29
|
+
FILE_PANEL = 'Audio File Format Settings'
|
|
30
|
+
CONSOLE_PANEL = 'Console and UI Settings'
|
|
31
|
+
RECORD_PANEL = 'Record Settings'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.app.command(help=app.HELP)
|
|
47
35
|
def recs(
|
|
48
36
|
#
|
|
49
37
|
# Directory settings
|
|
50
38
|
#
|
|
51
|
-
path:
|
|
52
|
-
RECS.path, help='Path
|
|
53
|
-
),
|
|
54
|
-
subdirectory: list[str] = Option(
|
|
55
|
-
RECS.subdirectory,
|
|
56
|
-
'-s',
|
|
57
|
-
'--subdirectory',
|
|
58
|
-
help='Organize files into subdirectories by channel, device or time.',
|
|
39
|
+
path: str = Argument(
|
|
40
|
+
RECS.path, help='Path or path pattern for recorded file locations'
|
|
59
41
|
),
|
|
60
42
|
#
|
|
61
|
-
# General
|
|
43
|
+
# General
|
|
62
44
|
#
|
|
45
|
+
calibrate: bool = Option(
|
|
46
|
+
RECS.calibrate,
|
|
47
|
+
'--calibrate',
|
|
48
|
+
help='Detect and print noise levels, do not record',
|
|
49
|
+
rich_help_panel=GENERAL_PANEL,
|
|
50
|
+
),
|
|
63
51
|
dry_run: bool = Option(
|
|
64
|
-
RECS.dry_run,
|
|
52
|
+
RECS.dry_run,
|
|
53
|
+
'-n',
|
|
54
|
+
'--dry-run',
|
|
55
|
+
help='Display levels only, do not record',
|
|
56
|
+
rich_help_panel=GENERAL_PANEL,
|
|
57
|
+
),
|
|
58
|
+
verbose: bool = Option(
|
|
59
|
+
RECS.verbose,
|
|
60
|
+
'-v',
|
|
61
|
+
'--verbose',
|
|
62
|
+
help='Print more stuff - currently does nothing',
|
|
63
|
+
rich_help_panel=GENERAL_PANEL,
|
|
65
64
|
),
|
|
65
|
+
#
|
|
66
|
+
# Subcommands
|
|
67
|
+
#
|
|
66
68
|
info: bool = Option(
|
|
67
|
-
RECS.info,
|
|
69
|
+
RECS.info,
|
|
70
|
+
'--info',
|
|
71
|
+
help='Display device info as JSON',
|
|
72
|
+
rich_help_panel=SUBCOMMANDS_PANEL,
|
|
68
73
|
),
|
|
69
74
|
list_types: bool = Option(
|
|
70
|
-
RECS.list_types,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
RECS.list_types,
|
|
76
|
+
'--list-types',
|
|
77
|
+
help='List all subtypes for each format as JSON',
|
|
78
|
+
rich_help_panel=SUBCOMMANDS_PANEL,
|
|
74
79
|
),
|
|
75
80
|
#
|
|
76
|
-
#
|
|
81
|
+
# Names
|
|
77
82
|
#
|
|
78
83
|
alias: list[str] = Option(
|
|
79
|
-
RECS.alias,
|
|
84
|
+
RECS.alias,
|
|
85
|
+
'-a',
|
|
86
|
+
'--alias',
|
|
87
|
+
click_type=app.AliasParam(),
|
|
88
|
+
help='Set aliases for devices or channels',
|
|
89
|
+
rich_help_panel=NAMES_PANEL,
|
|
90
|
+
),
|
|
91
|
+
devices: Path = Option(
|
|
92
|
+
RECS.devices,
|
|
93
|
+
help='A path to a JSON file with device definitions',
|
|
94
|
+
rich_help_panel=NAMES_PANEL,
|
|
80
95
|
),
|
|
81
|
-
devices: Path = Option(RECS.devices, help='A JSON file with device definitions'),
|
|
82
|
-
#
|
|
83
|
-
# Exclude or include devices or channels
|
|
84
|
-
#
|
|
85
96
|
exclude: list[str] = Option(
|
|
86
|
-
RECS.exclude,
|
|
97
|
+
RECS.exclude,
|
|
98
|
+
'-e',
|
|
99
|
+
'--exclude',
|
|
100
|
+
help='Exclude devices or channels',
|
|
101
|
+
rich_help_panel=NAMES_PANEL,
|
|
87
102
|
),
|
|
88
103
|
include: list[str] = Option(
|
|
89
|
-
RECS.include,
|
|
104
|
+
RECS.include,
|
|
105
|
+
'-i',
|
|
106
|
+
'--include',
|
|
107
|
+
help='Only include these devices or channels',
|
|
108
|
+
rich_help_panel=NAMES_PANEL,
|
|
90
109
|
),
|
|
91
110
|
#
|
|
92
|
-
#
|
|
111
|
+
# File
|
|
93
112
|
#
|
|
94
|
-
format:
|
|
95
|
-
|
|
96
|
-
|
|
113
|
+
format: types.Format = Option(
|
|
114
|
+
RECS.format,
|
|
115
|
+
'-f',
|
|
116
|
+
'--format',
|
|
117
|
+
click_type=app.FormatParam(),
|
|
118
|
+
help='Audio file format',
|
|
119
|
+
rich_help_panel=FILE_PANEL,
|
|
97
120
|
),
|
|
98
|
-
|
|
99
|
-
RECS.
|
|
121
|
+
metadata: list[str] = Option(
|
|
122
|
+
RECS.metadata,
|
|
123
|
+
'-m',
|
|
124
|
+
'--metadata',
|
|
125
|
+
click_type=app.MetadataParam(),
|
|
126
|
+
help='Metadata fields to add to output files',
|
|
127
|
+
rich_help_panel=FILE_PANEL,
|
|
128
|
+
),
|
|
129
|
+
sdtype: types.SdType
|
|
130
|
+
| None = Option(
|
|
131
|
+
RECS.sdtype,
|
|
132
|
+
'-d',
|
|
133
|
+
'--sdtype',
|
|
134
|
+
click_type=app.SdTypeParam(),
|
|
135
|
+
help='Integer or float number type for recording',
|
|
136
|
+
rich_help_panel=FILE_PANEL,
|
|
137
|
+
),
|
|
138
|
+
subtype: types.Subtype
|
|
139
|
+
| None = Option(
|
|
140
|
+
RECS.subtype,
|
|
141
|
+
'-u',
|
|
142
|
+
'--subtype',
|
|
143
|
+
click_type=app.SubtypeParam(),
|
|
144
|
+
help='Audio file subtype',
|
|
145
|
+
rich_help_panel=FILE_PANEL,
|
|
100
146
|
),
|
|
101
|
-
subtype: str = Option(RECS.subtype, '-u', '--subtype', help='File subtype'),
|
|
102
147
|
#
|
|
103
|
-
# Console
|
|
148
|
+
# Console
|
|
104
149
|
#
|
|
150
|
+
clear: bool = Option(
|
|
151
|
+
RECS.clear,
|
|
152
|
+
'-r',
|
|
153
|
+
'--clear',
|
|
154
|
+
help='Clear display on shutdown',
|
|
155
|
+
rich_help_panel=CONSOLE_PANEL,
|
|
156
|
+
),
|
|
105
157
|
silent: bool = Option(
|
|
106
|
-
RECS.silent,
|
|
158
|
+
RECS.silent,
|
|
159
|
+
'-s',
|
|
160
|
+
'--silent',
|
|
161
|
+
help='Do not display live updates',
|
|
162
|
+
rich_help_panel=CONSOLE_PANEL,
|
|
107
163
|
),
|
|
108
|
-
|
|
109
|
-
RECS.
|
|
164
|
+
sleep_time_device: str = Option(
|
|
165
|
+
RECS.sleep_time_device,
|
|
166
|
+
'--sleep-time-device',
|
|
167
|
+
click_type=app.TimeParam(),
|
|
168
|
+
help='How long to sleep between checking device',
|
|
169
|
+
rich_help_panel=CONSOLE_PANEL,
|
|
110
170
|
),
|
|
111
171
|
ui_refresh_rate: float = Option(
|
|
112
172
|
RECS.ui_refresh_rate,
|
|
113
173
|
'--ui-refresh-rate',
|
|
114
174
|
help='How many UI refreshes per second',
|
|
115
|
-
|
|
116
|
-
sleep_time: float = Option(
|
|
117
|
-
RECS.sleep_time, '--sleep-time', help='How long to sleep between data refreshes'
|
|
175
|
+
rich_help_panel=CONSOLE_PANEL,
|
|
118
176
|
),
|
|
119
177
|
#
|
|
120
|
-
#
|
|
178
|
+
# Record
|
|
121
179
|
#
|
|
122
180
|
infinite_length: bool = Option(
|
|
123
181
|
RECS.infinite_length,
|
|
124
|
-
|
|
182
|
+
'--infinite-length',
|
|
183
|
+
help='Ignore file size limits (4G on .wav, 2G on .aiff)',
|
|
184
|
+
rich_help_panel=RECORD_PANEL,
|
|
125
185
|
),
|
|
126
186
|
longest_file_time: str = Option(
|
|
127
|
-
RECS.longest_file_time,
|
|
187
|
+
RECS.longest_file_time,
|
|
188
|
+
click_type=app.TimeParam(),
|
|
189
|
+
help='Longest amount of time per file: 0 means infinite',
|
|
190
|
+
rich_help_panel=RECORD_PANEL,
|
|
128
191
|
),
|
|
129
|
-
moving_average_time:
|
|
130
|
-
RECS.moving_average_time,
|
|
192
|
+
moving_average_time: str = Option(
|
|
193
|
+
RECS.moving_average_time,
|
|
194
|
+
click_type=app.TimeParam(),
|
|
195
|
+
help='How long to average the volume display over',
|
|
196
|
+
rich_help_panel=RECORD_PANEL,
|
|
131
197
|
),
|
|
132
198
|
noise_floor: float = Option(
|
|
133
|
-
RECS.noise_floor,
|
|
199
|
+
RECS.noise_floor,
|
|
200
|
+
'-o',
|
|
201
|
+
'--noise-floor',
|
|
202
|
+
help='The noise floor in decibels',
|
|
203
|
+
rich_help_panel=RECORD_PANEL,
|
|
134
204
|
),
|
|
135
205
|
shortest_file_time: str = Option(
|
|
136
|
-
RECS.shortest_file_time,
|
|
206
|
+
RECS.shortest_file_time,
|
|
207
|
+
click_type=app.TimeParam(),
|
|
208
|
+
help='Shortest amount of time per file',
|
|
209
|
+
rich_help_panel=RECORD_PANEL,
|
|
137
210
|
),
|
|
138
|
-
quiet_after_end:
|
|
211
|
+
quiet_after_end: str = Option(
|
|
139
212
|
RECS.quiet_after_end,
|
|
140
213
|
'-c',
|
|
141
214
|
'--quiet-after-end',
|
|
142
|
-
|
|
215
|
+
click_type=app.TimeParam(),
|
|
216
|
+
help='How much quiet after the end',
|
|
217
|
+
rich_help_panel=RECORD_PANEL,
|
|
143
218
|
),
|
|
144
|
-
quiet_before_start:
|
|
219
|
+
quiet_before_start: str = Option(
|
|
145
220
|
RECS.quiet_before_start,
|
|
146
221
|
'-b',
|
|
147
222
|
'--quiet-before-start',
|
|
148
|
-
|
|
223
|
+
click_type=app.TimeParam(),
|
|
224
|
+
help='How much quiet before a recording',
|
|
225
|
+
rich_help_panel=RECORD_PANEL,
|
|
149
226
|
),
|
|
150
|
-
stop_after_quiet:
|
|
227
|
+
stop_after_quiet: str = Option(
|
|
151
228
|
RECS.stop_after_quiet,
|
|
152
229
|
'--stop-after-quiet',
|
|
153
|
-
|
|
230
|
+
click_type=app.TimeParam(),
|
|
231
|
+
help='How much quiet before stopping a recording',
|
|
232
|
+
rich_help_panel=RECORD_PANEL,
|
|
154
233
|
),
|
|
155
|
-
total_run_time:
|
|
234
|
+
total_run_time: str = Option(
|
|
156
235
|
RECS.total_run_time,
|
|
157
236
|
'-t',
|
|
158
237
|
'--total-run-time',
|
|
238
|
+
click_type=app.TimeParam(),
|
|
159
239
|
help='How many seconds to record? 0 means forever',
|
|
240
|
+
rich_help_panel=RECORD_PANEL,
|
|
160
241
|
),
|
|
161
|
-
) -> None:
|
|
162
|
-
|
|
242
|
+
) -> None:
|
|
243
|
+
c = cfg.Cfg(**locals())
|
|
163
244
|
|
|
164
|
-
from . import
|
|
245
|
+
from . import run_cli
|
|
165
246
|
|
|
166
|
-
|
|
247
|
+
run_cli.run_cli(c)
|
|
167
248
|
|
|
168
249
|
|
|
169
250
|
_USED_SINGLES = ''.join(sorted(_SINGLES))
|
|
170
251
|
_UNUSED_SINGLES = ''.join(sorted(set(string.ascii_lowercase) - set(_SINGLES)))
|
|
171
252
|
|
|
172
|
-
assert _USED_SINGLES == '
|
|
173
|
-
assert _UNUSED_SINGLES == '
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def run() -> int:
|
|
177
|
-
try:
|
|
178
|
-
app(standalone_mode=False)
|
|
179
|
-
return 0
|
|
180
|
-
|
|
181
|
-
except RecsError as e:
|
|
182
|
-
print('ERROR:', *e.args, file=sys.stderr)
|
|
183
|
-
|
|
184
|
-
except click.ClickException as e:
|
|
185
|
-
print(f'{e.__class__.__name__}: {e.message}', file=sys.stderr)
|
|
186
|
-
|
|
187
|
-
except click.Abort:
|
|
188
|
-
print('Aborted', file=sys.stderr)
|
|
189
|
-
|
|
190
|
-
except KeyboardInterrupt:
|
|
191
|
-
print('Interrupted', file=sys.stderr)
|
|
192
|
-
|
|
193
|
-
return -1
|
|
253
|
+
assert _USED_SINGLES == 'abcdefimnorstuv', _USED_SINGLES
|
|
254
|
+
assert _UNUSED_SINGLES == 'ghjklpqwxyz', _UNUSED_SINGLES
|
recs/cfg/device.py
CHANGED
|
@@ -8,10 +8,27 @@ import numpy as np
|
|
|
8
8
|
|
|
9
9
|
from recs.base import times
|
|
10
10
|
from recs.base.prefix_dict import PrefixDict
|
|
11
|
-
from recs.base.types import DeviceDict, SdType
|
|
12
|
-
from recs.
|
|
11
|
+
from recs.base.types import DeviceDict, SdType, Stop
|
|
12
|
+
from recs.cfg import hash_cmp
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
class Update(t.NamedTuple):
|
|
16
|
+
array: np.ndarray
|
|
17
|
+
timestamp: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DeviceCallback = t.Callable[[Update], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InputStream(t.Protocol):
|
|
24
|
+
def close(self, ignore_errors=True) -> None:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def start(self) -> None:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
def stop(self, ignore_errors=True) -> None:
|
|
31
|
+
pass
|
|
15
32
|
|
|
16
33
|
|
|
17
34
|
class InputDevice(hash_cmp.HashCmp):
|
|
@@ -25,64 +42,34 @@ class InputDevice(hash_cmp.HashCmp):
|
|
|
25
42
|
def __str__(self) -> str:
|
|
26
43
|
return self.name
|
|
27
44
|
|
|
28
|
-
@property
|
|
29
|
-
def is_online(self) -> bool:
|
|
30
|
-
# TODO: this is wrong!
|
|
31
|
-
# https://github.com/spatialaudio/python-sounddevice/issues/382
|
|
32
|
-
# return any(self.name == i['name'] for i in sd.query_devices())
|
|
33
|
-
return True
|
|
34
|
-
|
|
35
45
|
def input_stream(
|
|
36
|
-
self,
|
|
37
|
-
|
|
38
|
-
dtype: SdType,
|
|
39
|
-
stop: t.Callable[[], None],
|
|
40
|
-
) -> t.Iterator[None] | None:
|
|
46
|
+
self, device_callback: DeviceCallback, sdtype: SdType, on_error: Stop
|
|
47
|
+
) -> InputStream:
|
|
41
48
|
import sounddevice as sd
|
|
42
49
|
|
|
43
|
-
if not self.is_online:
|
|
44
|
-
return None
|
|
45
|
-
|
|
46
50
|
stream: sd.InputStream
|
|
47
51
|
|
|
48
|
-
def
|
|
49
|
-
# TODO: time is a _cffi_backend._CDataBase, not a float!
|
|
50
|
-
|
|
51
|
-
stream._recs_timestamp = times.time()
|
|
52
|
-
|
|
52
|
+
def callback(indata: np.ndarray, frames: int, time: t.Any, status: int) -> None:
|
|
53
53
|
if status: # pragma: no cover
|
|
54
|
-
# This has not yet happened, probably because we never get behind
|
|
55
|
-
# the device callback cycle.
|
|
56
54
|
print('Status', self, status, file=sys.stderr)
|
|
57
55
|
|
|
58
|
-
if not indata.size: # pragma: no cover
|
|
59
|
-
print('Empty block', self, file=sys.stderr)
|
|
60
|
-
return
|
|
61
|
-
|
|
62
56
|
try:
|
|
63
|
-
|
|
64
|
-
callback(indata.copy(), stream._recs_timestamp)
|
|
57
|
+
device_callback(Update(indata.copy(), times.timestamp()))
|
|
65
58
|
|
|
66
59
|
except Exception: # pragma: no cover
|
|
67
60
|
traceback.print_exc()
|
|
68
61
|
try:
|
|
69
|
-
|
|
70
|
-
except Exception:
|
|
71
|
-
traceback.print_exc()
|
|
72
|
-
try:
|
|
73
|
-
stop()
|
|
62
|
+
on_error()
|
|
74
63
|
except Exception:
|
|
75
64
|
traceback.print_exc()
|
|
76
65
|
|
|
77
|
-
assert dtype is not None # If sdtype is None, then the whole system blocks
|
|
78
66
|
stream = sd.InputStream(
|
|
79
|
-
callback=
|
|
67
|
+
callback=callback,
|
|
80
68
|
channels=self.channels,
|
|
81
69
|
device=self.name,
|
|
82
|
-
dtype=
|
|
70
|
+
dtype=sdtype,
|
|
83
71
|
samplerate=self.samplerate,
|
|
84
72
|
)
|
|
85
|
-
stream._recs_timestamp = times.time()
|
|
86
73
|
return stream
|
|
87
74
|
|
|
88
75
|
|
|
@@ -90,16 +77,23 @@ InputDevices = PrefixDict[InputDevice]
|
|
|
90
77
|
|
|
91
78
|
|
|
92
79
|
def get_input_devices(devices: t.Sequence[DeviceDict]) -> InputDevices:
|
|
93
|
-
return PrefixDict({d.name: d for i in devices if (d := InputDevice(i))})
|
|
80
|
+
return PrefixDict({d.name: d for i in devices if (d := InputDevice(i)).channels})
|
|
94
81
|
|
|
95
82
|
|
|
96
|
-
|
|
83
|
+
CMD = sys.executable, '-m', 'recs.base._query_device'
|
|
97
84
|
|
|
98
85
|
|
|
99
86
|
def query_devices() -> t.Sequence[DeviceDict]:
|
|
100
|
-
|
|
87
|
+
try:
|
|
88
|
+
r = sp.run(CMD, text=True, check=True, stdout=sp.PIPE)
|
|
89
|
+
except sp.CalledProcessError:
|
|
90
|
+
return []
|
|
101
91
|
return json.loads(r.stdout)
|
|
102
92
|
|
|
103
93
|
|
|
94
|
+
def input_names() -> t.Sequence[str]:
|
|
95
|
+
return sorted(str(i['name']) for i in query_devices())
|
|
96
|
+
|
|
97
|
+
|
|
104
98
|
def input_devices() -> InputDevices:
|
|
105
99
|
return get_input_devices(query_devices())
|
recs/{base → cfg}/metadata.py
RENAMED
|
@@ -2,13 +2,16 @@ import typing as t
|
|
|
2
2
|
|
|
3
3
|
import soundfile as sf
|
|
4
4
|
|
|
5
|
-
from . import RecsError
|
|
5
|
+
from recs.base import RecsError, prefix_dict
|
|
6
|
+
from recs.base.types import Format
|
|
6
7
|
|
|
7
8
|
RECS_USES = {'date', 'software', 'tracknumber'}
|
|
8
9
|
USABLE = {'album', 'artist', 'comment', 'copyright', 'genre', 'title'}
|
|
9
10
|
UNUSABLE = {'license'} # Can't be set for some reason
|
|
10
11
|
ALL = RECS_USES | USABLE | UNUSABLE
|
|
11
12
|
|
|
13
|
+
PREFIX_DICT = prefix_dict.PrefixDict({i: i for i in sorted(ALL)})
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
def to_dict(metadata: t.Sequence[str]) -> dict[str, str]:
|
|
14
17
|
result: dict[str, str] = {}
|
|
@@ -16,6 +19,12 @@ def to_dict(metadata: t.Sequence[str]) -> dict[str, str]:
|
|
|
16
19
|
|
|
17
20
|
for m in metadata:
|
|
18
21
|
name, eq, value = (i.strip() for i in m.partition('='))
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
name = PREFIX_DICT[name]
|
|
25
|
+
except KeyError:
|
|
26
|
+
pass
|
|
27
|
+
|
|
19
28
|
if not (name and eq and value):
|
|
20
29
|
errors.setdefault('malformed', []).append(m)
|
|
21
30
|
elif name in result:
|
|
@@ -29,10 +38,22 @@ def to_dict(metadata: t.Sequence[str]) -> dict[str, str]:
|
|
|
29
38
|
|
|
30
39
|
if errors:
|
|
31
40
|
msg = ', '.join(f'{k}: {v}' for k, v in errors.items())
|
|
32
|
-
raise RecsError(
|
|
41
|
+
raise RecsError(msg)
|
|
33
42
|
|
|
34
43
|
return result
|
|
35
44
|
|
|
36
45
|
|
|
37
46
|
def get_metadata(fp: sf.SoundFile) -> dict[str, str]:
|
|
38
47
|
return {k: v for k in ALL if (v := getattr(fp, k))}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
ALLOWS_METADATA = {
|
|
51
|
+
Format.aiff,
|
|
52
|
+
Format.caf,
|
|
53
|
+
Format.flac,
|
|
54
|
+
Format.mp3,
|
|
55
|
+
Format.ogg,
|
|
56
|
+
Format.rf64,
|
|
57
|
+
Format.wav,
|
|
58
|
+
Format.wavex,
|
|
59
|
+
}
|
recs/cfg/path_pattern.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import string
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import IntEnum, auto
|
|
5
|
+
|
|
6
|
+
from recs.base import RecsError
|
|
7
|
+
from recs.cfg import aliases, track
|
|
8
|
+
|
|
9
|
+
findall_strftime = re.compile('%.').findall
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_fields(s: str) -> list[str]:
|
|
13
|
+
return sorted(n for _, n, _, _ in string.Formatter().parse(s) if n)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Req(IntEnum):
|
|
17
|
+
device = auto()
|
|
18
|
+
channel = auto()
|
|
19
|
+
year = auto()
|
|
20
|
+
month = auto()
|
|
21
|
+
day = auto()
|
|
22
|
+
hour = auto()
|
|
23
|
+
minute = auto()
|
|
24
|
+
second = auto()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PathPattern:
|
|
28
|
+
def __init__(self, path: str) -> None:
|
|
29
|
+
str_parts = parse_fields(path)
|
|
30
|
+
time_parts = findall_strftime(path)
|
|
31
|
+
parts = set(time_parts + str_parts)
|
|
32
|
+
|
|
33
|
+
if bad := parts - FIELDS:
|
|
34
|
+
raise RecsError(f'Unknown: {", ".join(sorted(bad))}')
|
|
35
|
+
|
|
36
|
+
self.name_parts = tuple(i for i in str_parts if i not in FIELD_TO_PSTRING)
|
|
37
|
+
self.pstring_parts = tuple(i for i in str_parts if i in FIELD_TO_PSTRING)
|
|
38
|
+
|
|
39
|
+
used = set().union(*(FIELD_TO_REQUIRED[p] for p in parts))
|
|
40
|
+
unused = set(Req) - used
|
|
41
|
+
|
|
42
|
+
def rep(r: Req) -> str:
|
|
43
|
+
return (r in unused) * f'{{{r.name}}}'
|
|
44
|
+
|
|
45
|
+
y, m, d = rep(Req.year), rep(Req.month), rep(Req.day)
|
|
46
|
+
if (m and not (y or d)) or (not m and (y and d)):
|
|
47
|
+
raise RecsError(f'Must specify year or day with month: {path}')
|
|
48
|
+
date = y + m + d
|
|
49
|
+
|
|
50
|
+
h, m, s = rep(Req.hour), rep(Req.minute), rep(Req.second)
|
|
51
|
+
if (m and not (h or s)) or (not m and (h and s)):
|
|
52
|
+
raise RecsError(f'Must specify hour or second with minute: {path}')
|
|
53
|
+
time = h + m + s
|
|
54
|
+
|
|
55
|
+
d, c = rep(Req.device), rep(Req.channel)
|
|
56
|
+
dc = '{track}' if all((d, c)) else d + c
|
|
57
|
+
|
|
58
|
+
dt = f'{date}-{time}' if all((date, time)) else date + time
|
|
59
|
+
p = f'{dc} + {dt}' if all((dc, dt)) else dc + dt
|
|
60
|
+
|
|
61
|
+
self.path = f'{path}/{p}' if all((path, p)) else path + p
|
|
62
|
+
|
|
63
|
+
str_parts = parse_fields(self.path)
|
|
64
|
+
self.strf_parts = tuple(i for i in str_parts if i in FIELD_TO_PSTRING)
|
|
65
|
+
|
|
66
|
+
def times(self, ts: datetime) -> dict[str, str]:
|
|
67
|
+
return {k: ts.strftime(FIELD_TO_PSTRING[k]) for k in self.strf_parts}
|
|
68
|
+
|
|
69
|
+
def evaluate(
|
|
70
|
+
self,
|
|
71
|
+
track: track.Track,
|
|
72
|
+
aliases: aliases.Aliases,
|
|
73
|
+
timestamp: float,
|
|
74
|
+
index: int,
|
|
75
|
+
) -> str:
|
|
76
|
+
ts = datetime.fromtimestamp(timestamp)
|
|
77
|
+
s = ts.strftime(self.path)
|
|
78
|
+
|
|
79
|
+
return s.format(
|
|
80
|
+
channel=track.name,
|
|
81
|
+
device=aliases.display_name(track.device),
|
|
82
|
+
index=str(index),
|
|
83
|
+
track=aliases.display_name(track, short=False),
|
|
84
|
+
**self.times(ts),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
DATE = {Req.year, Req.month, Req.day}
|
|
89
|
+
TIME = {Req.hour, Req.minute, Req.second}
|
|
90
|
+
|
|
91
|
+
FIELD_TO_REQUIRED: dict[str, set[Req]] = {
|
|
92
|
+
'index': DATE | TIME,
|
|
93
|
+
'device': {Req.device},
|
|
94
|
+
'channel': {Req.channel},
|
|
95
|
+
'track': {Req.channel, Req.device},
|
|
96
|
+
#
|
|
97
|
+
'date': DATE,
|
|
98
|
+
'time': TIME,
|
|
99
|
+
'ddate': DATE,
|
|
100
|
+
'dtime': TIME,
|
|
101
|
+
'sdate': DATE,
|
|
102
|
+
'stime': TIME,
|
|
103
|
+
'timestamp': DATE | TIME,
|
|
104
|
+
#
|
|
105
|
+
'year': {Req.year},
|
|
106
|
+
'month': {Req.month},
|
|
107
|
+
'day': {Req.day},
|
|
108
|
+
'hour': {Req.hour},
|
|
109
|
+
'minute': {Req.minute},
|
|
110
|
+
'second': {Req.second},
|
|
111
|
+
#
|
|
112
|
+
'%A': set(),
|
|
113
|
+
'%B': {Req.month},
|
|
114
|
+
'%G': {Req.year},
|
|
115
|
+
'%H': {Req.hour},
|
|
116
|
+
'%I': set(),
|
|
117
|
+
'%M': {Req.minute},
|
|
118
|
+
'%S': {Req.second},
|
|
119
|
+
'%U': set(),
|
|
120
|
+
'%V': set(),
|
|
121
|
+
'%W': set(),
|
|
122
|
+
'%X': TIME,
|
|
123
|
+
'%Y': {Req.year},
|
|
124
|
+
'%Z': set(),
|
|
125
|
+
'%a': set(),
|
|
126
|
+
'%b': {Req.month},
|
|
127
|
+
'%c': DATE | TIME,
|
|
128
|
+
'%d': {Req.day},
|
|
129
|
+
'%f': set(),
|
|
130
|
+
'%j': {Req.month, Req.day},
|
|
131
|
+
'%m': {Req.month},
|
|
132
|
+
'%p': set(),
|
|
133
|
+
'%u': set(),
|
|
134
|
+
'%w': set(),
|
|
135
|
+
'%x': DATE,
|
|
136
|
+
'%y': {Req.year},
|
|
137
|
+
'%%': set(),
|
|
138
|
+
}
|
|
139
|
+
FIELDS = set(FIELD_TO_REQUIRED)
|
|
140
|
+
|
|
141
|
+
FIELD_TO_PSTRING: dict[str, str] = {
|
|
142
|
+
'date': '%Y%m%d',
|
|
143
|
+
'time': '%H%M%S',
|
|
144
|
+
'ddate': '%Y-%m-%d',
|
|
145
|
+
'dtime': '%H-%M-%S',
|
|
146
|
+
'sdate': '%Y/%m/%d',
|
|
147
|
+
'stime': '%H/%M/%S',
|
|
148
|
+
'timestamp': '%H%M%S_Y%m%d',
|
|
149
|
+
#
|
|
150
|
+
'year': '%Y',
|
|
151
|
+
'month': '%m',
|
|
152
|
+
'day': '%d',
|
|
153
|
+
'hour': '%H',
|
|
154
|
+
'minute': '%M',
|
|
155
|
+
'second': '%S',
|
|
156
|
+
}
|