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.
Files changed (61) hide show
  1. recs-0.10.0/PKG-INFO +141 -0
  2. recs-0.10.0/README.md +111 -0
  3. recs-0.10.0/pyproject.toml +67 -0
  4. recs-0.10.0/recs/.DS_Store +0 -0
  5. recs-0.10.0/recs/__init__.py +0 -0
  6. recs-0.10.0/recs/__main__.py +29 -0
  7. recs-0.10.0/recs/audio/.DS_Store +0 -0
  8. recs-0.10.0/recs/audio/__init__.py +0 -0
  9. recs-0.10.0/recs/audio/block.py +103 -0
  10. recs-0.10.0/recs/audio/channel_writer.py +238 -0
  11. recs-0.10.0/recs/audio/file_opener.py +50 -0
  12. recs-0.10.0/recs/audio/header_size.py +16 -0
  13. recs-0.10.0/recs/base/.DS_Store +0 -0
  14. recs-0.10.0/recs/base/__init__.py +5 -0
  15. recs-0.10.0/recs/base/_query_device.py +21 -0
  16. recs-0.10.0/recs/base/cfg_raw.py +61 -0
  17. recs-0.10.0/recs/base/prefix_dict.py +26 -0
  18. recs-0.10.0/recs/base/pyproject.py +16 -0
  19. recs-0.10.0/recs/base/state.py +73 -0
  20. recs-0.10.0/recs/base/times.py +50 -0
  21. recs-0.10.0/recs/base/type_conversions.py +26 -0
  22. recs-0.10.0/recs/base/types.py +51 -0
  23. recs-0.10.0/recs/cfg/.DS_Store +0 -0
  24. recs-0.10.0/recs/cfg/__init__.py +8 -0
  25. recs-0.10.0/recs/cfg/aliases.py +88 -0
  26. recs-0.10.0/recs/cfg/app.py +89 -0
  27. recs-0.10.0/recs/cfg/cfg.py +95 -0
  28. recs-0.10.0/recs/cfg/cli.py +273 -0
  29. recs-0.10.0/recs/cfg/device.py +87 -0
  30. recs-0.10.0/recs/cfg/file_source.py +61 -0
  31. recs-0.10.0/recs/cfg/hash_cmp.py +19 -0
  32. recs-0.10.0/recs/cfg/metadata.py +56 -0
  33. recs-0.10.0/recs/cfg/path_pattern.py +164 -0
  34. recs-0.10.0/recs/cfg/run_cli.py +34 -0
  35. recs-0.10.0/recs/cfg/source.py +42 -0
  36. recs-0.10.0/recs/cfg/time_settings.py +64 -0
  37. recs-0.10.0/recs/cfg/track.py +66 -0
  38. recs-0.10.0/recs/misc/.DS_Store +0 -0
  39. recs-0.10.0/recs/misc/__init__.py +0 -0
  40. recs-0.10.0/recs/misc/contexts.py +10 -0
  41. recs-0.10.0/recs/misc/counter.py +79 -0
  42. recs-0.10.0/recs/misc/file_list.py +31 -0
  43. recs-0.10.0/recs/misc/legal_filename.py +18 -0
  44. recs-0.10.0/recs/misc/log.py +43 -0
  45. recs-0.10.0/recs/ui/.DS_Store +0 -0
  46. recs-0.10.0/recs/ui/__init__.py +0 -0
  47. recs-0.10.0/recs/ui/full_state.py +55 -0
  48. recs-0.10.0/recs/ui/live.py +125 -0
  49. recs-0.10.0/recs/ui/recorder.py +65 -0
  50. recs-0.10.0/recs/ui/source_recorder.py +76 -0
  51. recs-0.10.0/recs/ui/source_tracks.py +75 -0
  52. recs-0.10.0/recs/ui/table.py +32 -0
  53. recs-0.9.2/PKG-INFO +0 -53
  54. recs-0.9.2/README.rst +0 -34
  55. recs-0.9.2/recs.egg-info/PKG-INFO +0 -53
  56. recs-0.9.2/recs.egg-info/SOURCES.txt +0 -8
  57. recs-0.9.2/recs.egg-info/dependency_links.txt +0 -1
  58. recs-0.9.2/recs.egg-info/top_level.txt +0 -1
  59. recs-0.9.2/recs.py +0 -140
  60. recs-0.9.2/setup.cfg +0 -8
  61. 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