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.
- {recs-0.2.0 → recs-0.3.0}/PKG-INFO +59 -17
- {recs-0.2.0 → recs-0.3.0}/README.md +53 -12
- {recs-0.2.0 → recs-0.3.0}/pyproject.toml +12 -8
- recs-0.3.0/recs/__main__.py +29 -0
- {recs-0.2.0 → recs-0.3.0}/recs/audio/block.py +2 -5
- recs-0.3.0/recs/audio/channel_writer.py +194 -0
- {recs-0.2.0 → recs-0.3.0}/recs/audio/file_opener.py +14 -15
- recs-0.3.0/recs/base/_query_device.py +21 -0
- {recs-0.2.0 → recs-0.3.0}/recs/base/cfg_raw.py +16 -14
- recs-0.3.0/recs/base/prefix_dict.py +26 -0
- recs-0.3.0/recs/base/state.py +73 -0
- recs-0.3.0/recs/base/times.py +50 -0
- {recs-0.2.0 → recs-0.3.0}/recs/base/type_conversions.py +5 -5
- {recs-0.2.0 → recs-0.3.0}/recs/base/types.py +2 -7
- recs-0.3.0/recs/cfg/__init__.py +15 -0
- {recs-0.2.0 → recs-0.3.0}/recs/cfg/aliases.py +32 -21
- recs-0.3.0/recs/cfg/app.py +87 -0
- recs-0.3.0/recs/cfg/cfg.py +83 -0
- recs-0.3.0/recs/cfg/cli.py +254 -0
- recs-0.3.0/recs/cfg/device.py +99 -0
- {recs-0.2.0/recs/base → recs-0.3.0/recs/cfg}/metadata.py +23 -2
- recs-0.3.0/recs/cfg/path_pattern.py +156 -0
- recs-0.3.0/recs/cfg/run_cli.py +43 -0
- recs-0.2.0/recs/base/times.py → recs-0.3.0/recs/cfg/time_settings.py +3 -51
- {recs-0.2.0 → recs-0.3.0}/recs/cfg/track.py +4 -4
- recs-0.3.0/recs/misc/contexts.py +10 -0
- {recs-0.2.0 → recs-0.3.0}/recs/misc/counter.py +9 -8
- {recs-0.2.0 → recs-0.3.0}/recs/misc/legal_filename.py +1 -1
- recs-0.3.0/recs/misc/log.py +43 -0
- recs-0.3.0/recs/ui/__init__.py +0 -0
- recs-0.3.0/recs/ui/device_process.py +37 -0
- recs-0.3.0/recs/ui/device_recorder.py +83 -0
- {recs-0.2.0 → recs-0.3.0}/recs/ui/device_tracks.py +9 -1
- recs-0.3.0/recs/ui/full_state.py +50 -0
- {recs-0.2.0 → recs-0.3.0}/recs/ui/live.py +28 -16
- recs-0.3.0/recs/ui/recorder.py +74 -0
- {recs-0.2.0 → recs-0.3.0}/recs/ui/table.py +1 -1
- recs-0.2.0/recs/__main__.py +0 -6
- recs-0.2.0/recs/audio/channel_writer.py +0 -178
- recs-0.2.0/recs/base/prefix_dict.py +0 -27
- recs-0.2.0/recs/cfg/__init__.py +0 -6
- recs-0.2.0/recs/cfg/cfg.py +0 -107
- recs-0.2.0/recs/cfg/cli.py +0 -193
- recs-0.2.0/recs/cfg/device.py +0 -105
- recs-0.2.0/recs/cfg/run.py +0 -32
- recs-0.2.0/recs/misc/recording_path.py +0 -47
- recs-0.2.0/recs/ui/__init__.py +0 -1
- recs-0.2.0/recs/ui/channel_recorder.py +0 -51
- recs-0.2.0/recs/ui/device_recorder.py +0 -99
- recs-0.2.0/recs/ui/recorder.py +0 -93
- {recs-0.2.0 → recs-0.3.0}/LICENSE +0 -0
- {recs-0.2.0 → recs-0.3.0}/recs/__init__.py +0 -0
- {recs-0.2.0 → recs-0.3.0}/recs/audio/__init__.py +0 -0
- {recs-0.2.0 → recs-0.3.0}/recs/audio/header_size.py +0 -0
- {recs-0.2.0 → recs-0.3.0}/recs/base/__init__.py +0 -0
- {recs-0.2.0 → recs-0.3.0}/recs/base/pyproject.py +0 -0
- {recs-0.2.0/recs/misc → recs-0.3.0/recs/cfg}/hash_cmp.py +0 -0
- {recs-0.2.0 → recs-0.3.0}/recs/misc/__init__.py +0 -0
- {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.
|
|
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
|
|
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
|
|
24
|
-
Requires-Dist: threa
|
|
25
|
-
Requires-Dist: tomli
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
and most of the value in this first step is the audio, so I
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
and most of the value in this first step is the audio, so I
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
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 = "
|
|
23
|
-
humanfriendly = "
|
|
24
|
-
tomli = "
|
|
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 = [
|
|
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 =
|
|
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 = ["
|
|
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)
|
|
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.
|
|
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
|
-
|
|
31
|
-
|
|
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(
|
|
36
|
-
index
|
|
37
|
-
|
|
38
|
-
path,
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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(
|
|
46
|
+
return self.open(f, metadata)
|
|
47
47
|
except FileExistsError:
|
|
48
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
52
|
-
moving_average_time: float = 1
|
|
53
|
-
noise_floor: float = 70
|
|
54
|
-
shortest_file_time:
|
|
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)
|