recs 0.9.2__tar.gz → 0.10.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.10.0/PKG-INFO +141 -0
- recs-0.10.0/README.md +111 -0
- recs-0.10.0/pyproject.toml +67 -0
- recs-0.10.0/recs/.DS_Store +0 -0
- recs-0.10.0/recs/__init__.py +0 -0
- recs-0.10.0/recs/__main__.py +29 -0
- recs-0.10.0/recs/audio/.DS_Store +0 -0
- recs-0.10.0/recs/audio/__init__.py +0 -0
- recs-0.10.0/recs/audio/block.py +103 -0
- recs-0.10.0/recs/audio/channel_writer.py +238 -0
- recs-0.10.0/recs/audio/file_opener.py +50 -0
- recs-0.10.0/recs/audio/header_size.py +16 -0
- recs-0.10.0/recs/base/.DS_Store +0 -0
- recs-0.10.0/recs/base/__init__.py +5 -0
- recs-0.10.0/recs/base/_query_device.py +21 -0
- recs-0.10.0/recs/base/cfg_raw.py +61 -0
- recs-0.10.0/recs/base/prefix_dict.py +26 -0
- recs-0.10.0/recs/base/pyproject.py +16 -0
- recs-0.10.0/recs/base/state.py +73 -0
- recs-0.10.0/recs/base/times.py +50 -0
- recs-0.10.0/recs/base/type_conversions.py +26 -0
- recs-0.10.0/recs/base/types.py +51 -0
- recs-0.10.0/recs/cfg/.DS_Store +0 -0
- recs-0.10.0/recs/cfg/__init__.py +8 -0
- recs-0.10.0/recs/cfg/aliases.py +88 -0
- recs-0.10.0/recs/cfg/app.py +89 -0
- recs-0.10.0/recs/cfg/cfg.py +95 -0
- recs-0.10.0/recs/cfg/cli.py +273 -0
- recs-0.10.0/recs/cfg/device.py +87 -0
- recs-0.10.0/recs/cfg/file_source.py +61 -0
- recs-0.10.0/recs/cfg/hash_cmp.py +19 -0
- recs-0.10.0/recs/cfg/metadata.py +56 -0
- recs-0.10.0/recs/cfg/path_pattern.py +164 -0
- recs-0.10.0/recs/cfg/run_cli.py +34 -0
- recs-0.10.0/recs/cfg/source.py +42 -0
- recs-0.10.0/recs/cfg/time_settings.py +64 -0
- recs-0.10.0/recs/cfg/track.py +66 -0
- recs-0.10.0/recs/misc/.DS_Store +0 -0
- recs-0.10.0/recs/misc/__init__.py +0 -0
- recs-0.10.0/recs/misc/contexts.py +10 -0
- recs-0.10.0/recs/misc/counter.py +79 -0
- recs-0.10.0/recs/misc/file_list.py +31 -0
- recs-0.10.0/recs/misc/legal_filename.py +18 -0
- recs-0.10.0/recs/misc/log.py +43 -0
- recs-0.10.0/recs/ui/.DS_Store +0 -0
- recs-0.10.0/recs/ui/__init__.py +0 -0
- recs-0.10.0/recs/ui/full_state.py +55 -0
- recs-0.10.0/recs/ui/live.py +125 -0
- recs-0.10.0/recs/ui/recorder.py +65 -0
- recs-0.10.0/recs/ui/source_recorder.py +76 -0
- recs-0.10.0/recs/ui/source_tracks.py +75 -0
- recs-0.10.0/recs/ui/table.py +32 -0
- recs-0.9.2/PKG-INFO +0 -53
- recs-0.9.2/README.rst +0 -34
- recs-0.9.2/recs.egg-info/PKG-INFO +0 -53
- recs-0.9.2/recs.egg-info/SOURCES.txt +0 -8
- recs-0.9.2/recs.egg-info/dependency_links.txt +0 -1
- recs-0.9.2/recs.egg-info/top_level.txt +0 -1
- recs-0.9.2/recs.py +0 -140
- recs-0.9.2/setup.cfg +0 -8
- recs-0.9.2/setup.py +0 -27
recs-0.10.0/PKG-INFO
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: recs
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: 🎙 The Universal Recorder 🎙
|
|
5
|
+
Author: Tom Ritchford
|
|
6
|
+
Author-email: Tom Ritchford <tom@swirly.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: dtyper
|
|
15
|
+
Requires-Dist: numpy
|
|
16
|
+
Requires-Dist: pyaudio
|
|
17
|
+
Requires-Dist: rich
|
|
18
|
+
Requires-Dist: sounddevice
|
|
19
|
+
Requires-Dist: soundfile
|
|
20
|
+
Requires-Dist: threa>=1.9.0
|
|
21
|
+
Requires-Dist: typer
|
|
22
|
+
Requires-Dist: impall
|
|
23
|
+
Requires-Dist: overrides
|
|
24
|
+
Requires-Dist: coverage
|
|
25
|
+
Requires-Dist: strenum
|
|
26
|
+
Requires-Dist: humanfriendly
|
|
27
|
+
Requires-Dist: tomli
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# 🎬 recs: the Universal Recorder 🎬
|
|
32
|
+
|
|
33
|
+
## Why should there be a record button at all?
|
|
34
|
+
|
|
35
|
+
A long time ago, I asked myself, "Why is there a record button and the possibility
|
|
36
|
+
of missing a take? Why not record everything?"
|
|
37
|
+
|
|
38
|
+
I sometimes play music, and I have mixed bands live, and I wanted a program that would
|
|
39
|
+
simply record everything at all times which I didn't have to stop and start, that I
|
|
40
|
+
could run completely separately from my other music programs.
|
|
41
|
+
|
|
42
|
+
Separately, I wanted to digitize a huge number of cassettes and LPs, so I wanted
|
|
43
|
+
a program that ran in the background and recorded everything except silence, so I just
|
|
44
|
+
play the music into the machine, and have it divided into pieces
|
|
45
|
+
|
|
46
|
+
Nothing like that existed so I wrote it.
|
|
47
|
+
|
|
48
|
+
## `recs`: the Universal Recorder
|
|
49
|
+
|
|
50
|
+
`recs` records any or every audio input on your machine, intelligently filters
|
|
51
|
+
out quiet, and stores the results in named, organized files.
|
|
52
|
+
|
|
53
|
+
Free, open-source, configurable, light on CPU and memory, and bulletproof
|
|
54
|
+
|
|
55
|
+
### Bulletproof?
|
|
56
|
+
|
|
57
|
+
It's not difficult to record some audio. Writing a program that runs continuously and
|
|
58
|
+
records audio even as real-world things happen is considerably harder.
|
|
59
|
+
|
|
60
|
+
It is impossible to prevent all loss, but considerable ingenuity and pulling of cables
|
|
61
|
+
has been used to mitigate and minimize this through software. See Appendix A.
|
|
62
|
+
|
|
63
|
+
### Universal?
|
|
64
|
+
|
|
65
|
+
It is a "Universal Recorder" because the plan to be able to record all streams of data:
|
|
66
|
+
audio is simply the start.
|
|
67
|
+
|
|
68
|
+
I have already [written code](https://github.com/rec/litoid) to do this for MIDI and DMX
|
|
69
|
+
- it works well but it isn't productionized, and I'll be folding that in in due time,
|
|
70
|
+
but most of the difficulty and most of the value in this first step is the audio, so I
|
|
71
|
+
have focused on just audio for this first release!
|
|
72
|
+
|
|
73
|
+
It might be that video is also incorporated in the far future, but the tooling is just
|
|
74
|
+
not there for Python yet, and it would be much too heavy to sit in the background all
|
|
75
|
+
the time and almost be forgotten about, so you could call it an Almost Universal
|
|
76
|
+
Recorder if you liked.
|
|
77
|
+
|
|
78
|
+
### Installation
|
|
79
|
+
|
|
80
|
+
`recs` is a standard PyPi package - use `poetry add recs` or `pip install recs` or your
|
|
81
|
+
favorite package manager.
|
|
82
|
+
|
|
83
|
+
To test, type `recs --info`, which prints JSON describing the input devices
|
|
84
|
+
you have. Here's a snippet from my machine:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
[
|
|
88
|
+
{
|
|
89
|
+
"name": "FLOW 8 (Recording)",
|
|
90
|
+
"index": 1,
|
|
91
|
+
"hostapi": 0,
|
|
92
|
+
"max_input_channels": 10,
|
|
93
|
+
"max_output_channels": 4,
|
|
94
|
+
"default_low_input_latency": 0.01,
|
|
95
|
+
"default_low_output_latency": 0.004354166666666667,
|
|
96
|
+
"default_high_input_latency": 0.1,
|
|
97
|
+
"default_high_output_latency": 0.0136875,
|
|
98
|
+
"default_samplerate": 48000.0
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"name": "USB PnP Sound Device",
|
|
102
|
+
"index": 2,
|
|
103
|
+
...
|
|
104
|
+
},
|
|
105
|
+
...
|
|
106
|
+
]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Basic Usage
|
|
110
|
+
|
|
111
|
+
Pick your nicest terminal program, go to a favorite directory with some free space, and
|
|
112
|
+
type:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
recs
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`recs` will start recording all the active audio channels into your current directory
|
|
119
|
+
and display the results in the terminal.
|
|
120
|
+
|
|
121
|
+
What "active"means can be customized rather a lot, but by default when a channel becomes
|
|
122
|
+
too quiet for more than a short time, it stops recording, and will start a new recording
|
|
123
|
+
automatically when the channel receives a signal.
|
|
124
|
+
|
|
125
|
+
Some care is taken to preserve the quiet before the start or after the end of a
|
|
126
|
+
recording to prevent abrupt transitions.
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
#### Appendix A: Failure modes
|
|
130
|
+
|
|
131
|
+
1. Hardware crash or power loss
|
|
132
|
+
2. Segfault or similar C/C++ errors
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
The aim is to be as bulletproof as possible. The pre-beta existing as I write this
|
|
136
|
+
(2023/11/19) seems to handle harder cases like hybernation well, and can
|
|
137
|
+
detect if a device goes offline and report it.
|
|
138
|
+
|
|
139
|
+
The holy grail is reconnecting to a device that comes back online: this is an
|
|
140
|
+
[unsolved problem](https://github.com/spatialaudio/python-sounddevice/issues/382)
|
|
141
|
+
in Python, I believe, but I am on my way to solving it.
|
recs-0.10.0/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# 🎬 recs: the Universal Recorder 🎬
|
|
2
|
+
|
|
3
|
+
## Why should there be a record button at all?
|
|
4
|
+
|
|
5
|
+
A long time ago, I asked myself, "Why is there a record button and the possibility
|
|
6
|
+
of missing a take? Why not record everything?"
|
|
7
|
+
|
|
8
|
+
I sometimes play music, and I have mixed bands live, and I wanted a program that would
|
|
9
|
+
simply record everything at all times which I didn't have to stop and start, that I
|
|
10
|
+
could run completely separately from my other music programs.
|
|
11
|
+
|
|
12
|
+
Separately, I wanted to digitize a huge number of cassettes and LPs, so I wanted
|
|
13
|
+
a program that ran in the background and recorded everything except silence, so I just
|
|
14
|
+
play the music into the machine, and have it divided into pieces
|
|
15
|
+
|
|
16
|
+
Nothing like that existed so I wrote it.
|
|
17
|
+
|
|
18
|
+
## `recs`: the Universal Recorder
|
|
19
|
+
|
|
20
|
+
`recs` records any or every audio input on your machine, intelligently filters
|
|
21
|
+
out quiet, and stores the results in named, organized files.
|
|
22
|
+
|
|
23
|
+
Free, open-source, configurable, light on CPU and memory, and bulletproof
|
|
24
|
+
|
|
25
|
+
### Bulletproof?
|
|
26
|
+
|
|
27
|
+
It's not difficult to record some audio. Writing a program that runs continuously and
|
|
28
|
+
records audio even as real-world things happen is considerably harder.
|
|
29
|
+
|
|
30
|
+
It is impossible to prevent all loss, but considerable ingenuity and pulling of cables
|
|
31
|
+
has been used to mitigate and minimize this through software. See Appendix A.
|
|
32
|
+
|
|
33
|
+
### Universal?
|
|
34
|
+
|
|
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
|
+
|
|
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
|
+
|
|
43
|
+
It might be that video is also incorporated in the far future, but the tooling is just
|
|
44
|
+
not there for Python yet, and it would be much too heavy to sit in the background all
|
|
45
|
+
the time and almost be forgotten about, so you could call it an Almost Universal
|
|
46
|
+
Recorder if you liked.
|
|
47
|
+
|
|
48
|
+
### Installation
|
|
49
|
+
|
|
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.
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
#### Appendix A: Failure modes
|
|
100
|
+
|
|
101
|
+
1. Hardware crash or power loss
|
|
102
|
+
2. Segfault or similar C/C++ errors
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
The aim is to be as bulletproof as possible. The pre-beta existing as I write this
|
|
106
|
+
(2023/11/19) seems to handle harder cases like hybernation well, and can
|
|
107
|
+
detect if a device goes offline and report it.
|
|
108
|
+
|
|
109
|
+
The holy grail is reconnecting to a device that comes back online: this is an
|
|
110
|
+
[unsolved problem](https://github.com/spatialaudio/python-sounddevice/issues/382)
|
|
111
|
+
in Python, I believe, but I am on my way to solving it.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "recs"
|
|
3
|
+
version = "0.10.0"
|
|
4
|
+
description = "🎙 The Universal Recorder 🎙"
|
|
5
|
+
authors = [{ name = "Tom Ritchford", email = "tom@swirly.com" }]
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
license = "MIT"
|
|
9
|
+
classifiers = ["Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"dtyper",
|
|
12
|
+
"numpy",
|
|
13
|
+
"pyaudio",
|
|
14
|
+
"rich",
|
|
15
|
+
"sounddevice",
|
|
16
|
+
"soundfile",
|
|
17
|
+
"threa>=1.9.0",
|
|
18
|
+
"typer",
|
|
19
|
+
"impall",
|
|
20
|
+
"overrides",
|
|
21
|
+
"coverage",
|
|
22
|
+
"strenum",
|
|
23
|
+
"humanfriendly",
|
|
24
|
+
"tomli",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
recs = "recs.base.cli:run"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"coverage>=7.4.1",
|
|
33
|
+
"pytest",
|
|
34
|
+
"pyupgrade>=3.21.2",
|
|
35
|
+
"ruff",
|
|
36
|
+
"tdir",
|
|
37
|
+
"ty>=0.0.14",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.uv]
|
|
41
|
+
|
|
42
|
+
[tool.uv.build-backend]
|
|
43
|
+
module-root = ""
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["uv_build>=0.9.0,<0.10.0"]
|
|
47
|
+
build-backend = "uv_build"
|
|
48
|
+
|
|
49
|
+
[tool.coverage.run]
|
|
50
|
+
branch = true
|
|
51
|
+
source = ["recs"]
|
|
52
|
+
omit = [
|
|
53
|
+
"recs/__main__.py",
|
|
54
|
+
"recs/cfg/cli.py",
|
|
55
|
+
"recs/cfg/app.py",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.coverage.report]
|
|
59
|
+
fail_under = 91
|
|
60
|
+
skip_covered = true
|
|
61
|
+
exclude_lines = ["pragma: no cover", "if False:", "if __name__ == .__main__.:", "raise NotImplementedError"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
line-length = 88
|
|
65
|
+
|
|
66
|
+
[tool.ruff.format]
|
|
67
|
+
quote-style = "single"
|
|
Binary file
|
|
File without changes
|
|
@@ -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())
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# mypy: disable-error-code="no-any-return, type-arg"
|
|
2
|
+
|
|
3
|
+
import dataclasses as dc
|
|
4
|
+
import numbers
|
|
5
|
+
import typing as t
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from recs.cfg.source import to_matrix
|
|
11
|
+
|
|
12
|
+
_EMPTY_SEEN = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dc.dataclass(frozen=True)
|
|
16
|
+
class Block:
|
|
17
|
+
block: np.ndarray
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
if not self.block.size:
|
|
21
|
+
raise ValueError('Empty block')
|
|
22
|
+
self.__dict__['block'] = to_matrix(self.block)
|
|
23
|
+
|
|
24
|
+
def __len__(self) -> int:
|
|
25
|
+
return self.block.shape[0]
|
|
26
|
+
|
|
27
|
+
def __getitem__(self, index: int | slice) -> 'Block':
|
|
28
|
+
return Block(self.block[index])
|
|
29
|
+
|
|
30
|
+
@cached_property
|
|
31
|
+
def is_float(self) -> bool:
|
|
32
|
+
return not issubclass(self.block.dtype.type, numbers.Integral)
|
|
33
|
+
|
|
34
|
+
@cached_property
|
|
35
|
+
def scale(self) -> float:
|
|
36
|
+
if self.is_float:
|
|
37
|
+
return 1
|
|
38
|
+
return float(1 << (8 * self.block.dtype.itemsize - 1))
|
|
39
|
+
|
|
40
|
+
@cached_property
|
|
41
|
+
def volume(self) -> float:
|
|
42
|
+
return sum(self.amplitude) / len(self.amplitude)
|
|
43
|
+
|
|
44
|
+
@cached_property
|
|
45
|
+
def channel_count(self) -> int:
|
|
46
|
+
return (self.block.shape + (1,))[1]
|
|
47
|
+
|
|
48
|
+
@cached_property
|
|
49
|
+
def amplitude(self) -> np.ndarray:
|
|
50
|
+
return (self.max - self.min) / (2 * self.scale)
|
|
51
|
+
|
|
52
|
+
@cached_property
|
|
53
|
+
def max(self) -> np.ndarray:
|
|
54
|
+
return self.block.max(0)
|
|
55
|
+
|
|
56
|
+
@cached_property
|
|
57
|
+
def min(self) -> np.ndarray:
|
|
58
|
+
return self.block.min(0)
|
|
59
|
+
|
|
60
|
+
@cached_property
|
|
61
|
+
def asfloat(self) -> 'Block':
|
|
62
|
+
if self.is_float:
|
|
63
|
+
return self
|
|
64
|
+
b = self.block.astype('double' if self.block.dtype.itemsize > 4 else 'float')
|
|
65
|
+
b /= self.scale
|
|
66
|
+
return Block(b)
|
|
67
|
+
|
|
68
|
+
@cached_property
|
|
69
|
+
def rms(self) -> np.ndarray:
|
|
70
|
+
b = self.asfloat.block
|
|
71
|
+
if b is self.block:
|
|
72
|
+
b = b * b
|
|
73
|
+
else:
|
|
74
|
+
b *= b
|
|
75
|
+
return np.sqrt(b.mean(0))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dc.dataclass
|
|
79
|
+
class Blocks:
|
|
80
|
+
blocks: list[Block] = dc.field(default_factory=list)
|
|
81
|
+
duration: int = 0
|
|
82
|
+
|
|
83
|
+
def append(self, block: Block) -> None:
|
|
84
|
+
self.blocks.append(block)
|
|
85
|
+
self.duration += len(block)
|
|
86
|
+
|
|
87
|
+
def clear(self) -> None:
|
|
88
|
+
self.duration = 0
|
|
89
|
+
self.blocks.clear()
|
|
90
|
+
|
|
91
|
+
def clip(self, sample_length: int, from_start: bool) -> t.Sequence[Block]:
|
|
92
|
+
clipped = []
|
|
93
|
+
assert sample_length >= 0
|
|
94
|
+
while self.duration > sample_length:
|
|
95
|
+
clipped.append(self.blocks.pop(0 if from_start else -1))
|
|
96
|
+
self.duration -= len(clipped[-1])
|
|
97
|
+
return clipped
|
|
98
|
+
|
|
99
|
+
def __iter__(self) -> t.Iterator[Block]:
|
|
100
|
+
return iter(self.blocks)
|
|
101
|
+
|
|
102
|
+
def __getitem__(self, i: int) -> Block:
|
|
103
|
+
return self.blocks[i]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import sys
|
|
3
|
+
import typing as t
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from threading import Lock
|
|
7
|
+
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
from overrides import override
|
|
10
|
+
from soundfile import SoundFile
|
|
11
|
+
from threa import Runnable
|
|
12
|
+
|
|
13
|
+
from recs.base.state import ChannelState
|
|
14
|
+
from recs.base.type_conversions import SUBTYPE_TO_SDTYPE
|
|
15
|
+
from recs.base.types import SDTYPE, Active, Format, SdType
|
|
16
|
+
from recs.cfg import Cfg, Track, time_settings
|
|
17
|
+
from recs.misc import counter, file_list
|
|
18
|
+
|
|
19
|
+
from .block import Block, Blocks
|
|
20
|
+
from .file_opener import FileOpener
|
|
21
|
+
from .header_size import header_size
|
|
22
|
+
|
|
23
|
+
URL = 'https://github.com/rec/recs'
|
|
24
|
+
|
|
25
|
+
BUFFER = 0x80
|
|
26
|
+
MAX_WAV_SIZE = 0x1_0000_0000 - BUFFER
|
|
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_file: int = 0
|
|
39
|
+
|
|
40
|
+
frames_in_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
|
+
_sfs: t.Sequence[SoundFile] = ()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def active(self) -> Active:
|
|
52
|
+
return Active.active if self._sfs 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.cfg = cfg
|
|
60
|
+
self.do_not_record = cfg.dry_run or cfg.calibrate
|
|
61
|
+
self.metadata = cfg.metadata
|
|
62
|
+
self.times = times
|
|
63
|
+
self.track = track
|
|
64
|
+
|
|
65
|
+
self._blocks = Blocks()
|
|
66
|
+
self._lock = Lock()
|
|
67
|
+
|
|
68
|
+
if track.source.format is None or cfg.cfg.formats:
|
|
69
|
+
self.formats = cfg.formats
|
|
70
|
+
else:
|
|
71
|
+
self.formats = [track.source.format]
|
|
72
|
+
|
|
73
|
+
if track.source.subtype is None or cfg.cfg.subtype:
|
|
74
|
+
subtype = cfg.subtype
|
|
75
|
+
else:
|
|
76
|
+
subtype = track.source.subtype
|
|
77
|
+
|
|
78
|
+
if track.source.subtype is None or cfg.cfg.sdtype:
|
|
79
|
+
sdtype = cfg.sdtype or SDTYPE
|
|
80
|
+
else:
|
|
81
|
+
sdtype = SUBTYPE_TO_SDTYPE[track.source.subtype]
|
|
82
|
+
|
|
83
|
+
self.files_written = file_list.FileList()
|
|
84
|
+
self.frame_size = ITEMSIZE[sdtype] * len(track.channels)
|
|
85
|
+
self.longest_file_frames = times.longest_file_time
|
|
86
|
+
|
|
87
|
+
self.openers = [
|
|
88
|
+
FileOpener(
|
|
89
|
+
channels=len(track.channels),
|
|
90
|
+
format=f,
|
|
91
|
+
samplerate=track.source.samplerate,
|
|
92
|
+
subtype=subtype,
|
|
93
|
+
)
|
|
94
|
+
for f in self.formats
|
|
95
|
+
]
|
|
96
|
+
self._volume = counter.MovingBlock(times.moving_average_time)
|
|
97
|
+
|
|
98
|
+
def size(f: str) -> int:
|
|
99
|
+
return (
|
|
100
|
+
MAX_WAV_SIZE if f == Format.wav and not self.cfg.infinite_length else 0
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
self.largest_file_size = max(0, *(size(f) for f in cfg.formats))
|
|
104
|
+
|
|
105
|
+
def to_block(self, array: NDArray) -> Block:
|
|
106
|
+
return Block(array[:, self.track.slice])
|
|
107
|
+
|
|
108
|
+
def receive_update(
|
|
109
|
+
self, block: Block, timestamp: float, should_record: bool = False
|
|
110
|
+
) -> ChannelState:
|
|
111
|
+
with self._lock:
|
|
112
|
+
should_record = should_record or self.should_record(block)
|
|
113
|
+
return self._receive_block(block, timestamp, should_record)
|
|
114
|
+
|
|
115
|
+
def should_record(self, block: Block) -> bool:
|
|
116
|
+
return (
|
|
117
|
+
self.times.record_everything
|
|
118
|
+
or block.volume >= self.times.noise_floor_amplitude
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@override
|
|
122
|
+
def stop(self) -> None:
|
|
123
|
+
with self._lock:
|
|
124
|
+
self.running = False
|
|
125
|
+
self._write_and_close()
|
|
126
|
+
self.stopped = True
|
|
127
|
+
|
|
128
|
+
def _close(self) -> None:
|
|
129
|
+
sfs, self._sfs = self._sfs, ()
|
|
130
|
+
for sf in sfs:
|
|
131
|
+
if sf.frames and sf.frames >= self.times.shortest_file_time:
|
|
132
|
+
print(sf.name, file=sys.stderr)
|
|
133
|
+
sf.close()
|
|
134
|
+
else:
|
|
135
|
+
with contextlib.suppress(Exception):
|
|
136
|
+
sf.close()
|
|
137
|
+
with contextlib.suppress(Exception):
|
|
138
|
+
Path(sf.name).unlink()
|
|
139
|
+
|
|
140
|
+
def _open(self, offset: int) -> t.Sequence[SoundFile]:
|
|
141
|
+
timestamp = self.timestamp - offset / self.track.source.samplerate
|
|
142
|
+
date = datetime.fromtimestamp(timestamp).isoformat()
|
|
143
|
+
index = 1 + len(self.files_written)
|
|
144
|
+
metadata = {'date': date, 'software': URL, 'tracknumber': str(index)}
|
|
145
|
+
metadata |= self.metadata
|
|
146
|
+
|
|
147
|
+
self.bytes_in_file = max(header_size(metadata, f) for f in self.cfg.formats)
|
|
148
|
+
self.frames_in_file = 0
|
|
149
|
+
|
|
150
|
+
path = self.cfg.output_directory.make_path(
|
|
151
|
+
self.track, self.cfg.aliases, timestamp, index
|
|
152
|
+
)
|
|
153
|
+
sfs = [o.create(metadata, path) for o in self.openers]
|
|
154
|
+
self.files_written.extend(Path(sf.name) for sf in sfs)
|
|
155
|
+
return sfs
|
|
156
|
+
|
|
157
|
+
def _receive_block(
|
|
158
|
+
self, block: Block, timestamp: float, should_record: bool
|
|
159
|
+
) -> ChannelState:
|
|
160
|
+
saved_state = self._state(
|
|
161
|
+
max_amp=max(block.max) / block.scale,
|
|
162
|
+
min_amp=min(block.min) / block.scale,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
dt = self.timestamp - timestamp
|
|
166
|
+
self.timestamp = timestamp
|
|
167
|
+
self._volume(block)
|
|
168
|
+
|
|
169
|
+
if not self.do_not_record and (self._sfs or not self.stopped):
|
|
170
|
+
expected_dt = len(block) / self.track.source.samplerate
|
|
171
|
+
|
|
172
|
+
if dt > expected_dt * BLOCK_FUZZ: # We were asleep, or otherwise lost time
|
|
173
|
+
self._write_and_close()
|
|
174
|
+
|
|
175
|
+
self._blocks.append(block)
|
|
176
|
+
|
|
177
|
+
if should_record:
|
|
178
|
+
if not self._sfs: # Record some quiet before the first block
|
|
179
|
+
length = self.times.quiet_before_start + len(self._blocks[-1])
|
|
180
|
+
self._blocks.clip(length, from_start=True)
|
|
181
|
+
|
|
182
|
+
self._write_blocks(self._blocks)
|
|
183
|
+
self._blocks.clear()
|
|
184
|
+
|
|
185
|
+
if self.stopped or self._blocks.duration > self.times.stop_after_quiet:
|
|
186
|
+
self._write_and_close()
|
|
187
|
+
|
|
188
|
+
return self._state() - saved_state
|
|
189
|
+
|
|
190
|
+
def _state(self, **kwargs: t.Any) -> ChannelState:
|
|
191
|
+
return ChannelState(
|
|
192
|
+
file_count=len(self.files_written),
|
|
193
|
+
file_size=self.files_written.total_size,
|
|
194
|
+
is_active=bool(self._sfs),
|
|
195
|
+
recorded_time=self.frames_written / self.track.source.samplerate,
|
|
196
|
+
timestamp=self.timestamp,
|
|
197
|
+
volume=tuple(self._volume.mean()),
|
|
198
|
+
**kwargs,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _write_and_close(self) -> None:
|
|
202
|
+
# Record some quiet after the last block
|
|
203
|
+
removed = self._blocks.clip(self.times.quiet_after_end, from_start=False)
|
|
204
|
+
|
|
205
|
+
if self._sfs and removed:
|
|
206
|
+
self._write_blocks(reversed(removed))
|
|
207
|
+
|
|
208
|
+
self._close()
|
|
209
|
+
|
|
210
|
+
def _write_blocks(self, blox: t.Iterable[Block]) -> None:
|
|
211
|
+
blocks = list(blox)
|
|
212
|
+
|
|
213
|
+
# The last block in the list ends at self.timestamp so
|
|
214
|
+
# we keep track of the sample offset before that
|
|
215
|
+
offset = -sum(len(b) for b in blocks)
|
|
216
|
+
|
|
217
|
+
for b in blocks:
|
|
218
|
+
# Check if this block will overrun the file size or length
|
|
219
|
+
remains: list[int] = []
|
|
220
|
+
|
|
221
|
+
if self.longest_file_frames:
|
|
222
|
+
remains.append(self.longest_file_frames - self.frames_in_file)
|
|
223
|
+
|
|
224
|
+
if self._sfs and self.largest_file_size:
|
|
225
|
+
file_bytes = self.largest_file_size - self.bytes_in_file
|
|
226
|
+
remains.append(file_bytes // self.frame_size)
|
|
227
|
+
|
|
228
|
+
if remains and min(remains) <= len(b):
|
|
229
|
+
self._close()
|
|
230
|
+
|
|
231
|
+
self._sfs = self._sfs or self._open(offset)
|
|
232
|
+
for sf in self._sfs:
|
|
233
|
+
sf.write(b.block)
|
|
234
|
+
offset += len(b)
|
|
235
|
+
|
|
236
|
+
self.frames_in_file += len(b)
|
|
237
|
+
self.frames_written += len(b)
|
|
238
|
+
self.bytes_in_file += len(b) * self.frame_size
|