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/run_cli.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import soundfile as sf
|
|
4
|
+
|
|
5
|
+
from recs.base.types import Format, SdType
|
|
6
|
+
from recs.cfg import device
|
|
7
|
+
from recs.ui.recorder import Recorder
|
|
8
|
+
|
|
9
|
+
from . import Cfg
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_cli(cfg: Cfg) -> None:
|
|
13
|
+
if cfg.info:
|
|
14
|
+
return _info()
|
|
15
|
+
|
|
16
|
+
if cfg.list_types:
|
|
17
|
+
return _list_types()
|
|
18
|
+
|
|
19
|
+
rec = Recorder(cfg)
|
|
20
|
+
try:
|
|
21
|
+
rec.run_recorder()
|
|
22
|
+
finally:
|
|
23
|
+
if cfg.calibrate:
|
|
24
|
+
states = rec.state.state.items()
|
|
25
|
+
d = {j: {k: v.db_range for k, v in u.items()} for j, u in states}
|
|
26
|
+
d2 = {'(all)': rec.state.total.db_range}
|
|
27
|
+
print(json.dumps(d | d2, indent=2))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _list_types() -> None:
|
|
31
|
+
avail = sf.available_formats()
|
|
32
|
+
fmts = [f.upper() for f in Format]
|
|
33
|
+
formats = {f: [avail[f], sf.available_subtypes(f)] for f in fmts}
|
|
34
|
+
sdtypes = [str(s) for s in SdType]
|
|
35
|
+
d = {'formats': formats, 'sdtypes': sdtypes}
|
|
36
|
+
|
|
37
|
+
print(json.dumps(d, indent=4))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _info() -> None:
|
|
41
|
+
info = device.query_devices()
|
|
42
|
+
info2 = [i for i in info if i['max_input_channels']]
|
|
43
|
+
print(json.dumps(info2, indent=4))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import dataclasses as dc
|
|
2
|
+
import math
|
|
3
|
+
import typing as t
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
|
|
6
|
+
T = t.TypeVar('T', float, int)
|
|
7
|
+
NO_SCALE = ('noise_floor',)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def db_to_amplitude(db: float) -> float:
|
|
11
|
+
return 10 ** (-db / 20)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def amplitude_to_db(amp: float) -> float:
|
|
15
|
+
if amp > 0:
|
|
16
|
+
return -20 * math.log10(amp)
|
|
17
|
+
return float('inf')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dc.dataclass(frozen=True)
|
|
21
|
+
class TimeSettings(t.Generic[T]):
|
|
22
|
+
"""Amounts of time are specified as seconds in the input but converted
|
|
23
|
+
to samples when we find out the sample rate
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
#: Longest amount of time per file: 0 means infinite
|
|
27
|
+
longest_file_time: T = t.cast(T, 0)
|
|
28
|
+
|
|
29
|
+
#: Shortest amount of time per file
|
|
30
|
+
shortest_file_time: T = t.cast(T, 0)
|
|
31
|
+
|
|
32
|
+
#: Amount of quiet at the start
|
|
33
|
+
quiet_before_start: T = t.cast(T, 0)
|
|
34
|
+
|
|
35
|
+
#: Amount of quiet at the end
|
|
36
|
+
quiet_after_end: T = t.cast(T, 0)
|
|
37
|
+
|
|
38
|
+
#: Amount of quiet before stopping a recording
|
|
39
|
+
stop_after_quiet: T = t.cast(T, 0)
|
|
40
|
+
|
|
41
|
+
# Time for moving averages for the meters
|
|
42
|
+
moving_average_time: T = t.cast(T, 0)
|
|
43
|
+
|
|
44
|
+
#: The noise floor in decibels
|
|
45
|
+
noise_floor: float = 70
|
|
46
|
+
|
|
47
|
+
#: Amount of total time to run. 0 or less means "run forever"
|
|
48
|
+
total_run_time: T = t.cast(T, 0)
|
|
49
|
+
|
|
50
|
+
@cached_property
|
|
51
|
+
def noise_floor_amplitude(self) -> float:
|
|
52
|
+
return db_to_amplitude(self.noise_floor)
|
|
53
|
+
|
|
54
|
+
def __post_init__(self):
|
|
55
|
+
if negative_fields := [k for k, v in dc.asdict(self).items() if v < 0]:
|
|
56
|
+
raise ValueError(f'TimeSettings cannot be negative: {negative_fields=}')
|
|
57
|
+
|
|
58
|
+
def scale(self, samplerate: float | int) -> 'TimeSettings[int]':
|
|
59
|
+
it = dc.asdict(self).items()
|
|
60
|
+
d = {k: v if k in NO_SCALE else round(samplerate * v) for k, v in it}
|
|
61
|
+
return TimeSettings[int](**d)
|
recs/cfg/track.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from recs.base import RecsError
|
|
2
|
-
from recs.misc import hash_cmp
|
|
3
2
|
|
|
3
|
+
from . import hash_cmp
|
|
4
4
|
from .device import InputDevice
|
|
5
5
|
|
|
6
6
|
__all__ = ('Track',)
|
|
@@ -21,15 +21,15 @@ class Track(hash_cmp.HashCmp):
|
|
|
21
21
|
if self.channels:
|
|
22
22
|
a, b = self.channels[0], self.channels[-1]
|
|
23
23
|
self.slice = slice(a - 1, b)
|
|
24
|
-
self.
|
|
24
|
+
self.name = f'{a}' if a == b else f'{a}-{b}'
|
|
25
25
|
|
|
26
26
|
else:
|
|
27
27
|
self.slice = slice(0)
|
|
28
|
-
self.
|
|
28
|
+
self.name = ''
|
|
29
29
|
|
|
30
30
|
def __str__(self) -> str:
|
|
31
31
|
if self.channels:
|
|
32
|
-
return f'{self.device.name} + {self.
|
|
32
|
+
return f'{self.device.name} + {self.name}'
|
|
33
33
|
return self.device.name
|
|
34
34
|
|
|
35
35
|
def __repr__(self) -> str:
|
recs/misc/contexts.py
ADDED
recs/misc/counter.py
CHANGED
|
@@ -51,20 +51,21 @@ class Accumulator:
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
class MovingBlock:
|
|
54
|
-
|
|
54
|
+
_dq: deque[np.ndarray] | None = None
|
|
55
55
|
|
|
56
56
|
def __init__(self, moving_average_time: int):
|
|
57
57
|
self.moving_average_time = moving_average_time
|
|
58
58
|
|
|
59
59
|
def __call__(self, b: Block) -> None:
|
|
60
|
-
if
|
|
60
|
+
if self._dq is None:
|
|
61
61
|
maxlen = int(0.5 + self.moving_average_time / len(b))
|
|
62
|
-
self.
|
|
62
|
+
self._dq = deque((), maxlen)
|
|
63
63
|
|
|
64
|
-
self.
|
|
64
|
+
self._dq.append(b.amplitude)
|
|
65
65
|
|
|
66
|
-
def mean(self) ->
|
|
67
|
-
if not self.
|
|
68
|
-
return 0
|
|
66
|
+
def mean(self) -> np.ndarray:
|
|
67
|
+
if not self._dq:
|
|
68
|
+
return np.array([0])
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
it = (d for i, d in enumerate(self._dq) if i)
|
|
71
|
+
return sum(it, start=self._dq[0]) / len(self._dq)
|
recs/misc/legal_filename.py
CHANGED
recs/misc/log.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import typing as t
|
|
3
|
+
from functools import cache, wraps
|
|
4
|
+
|
|
5
|
+
C = t.TypeVar('C', bound=t.Callable)
|
|
6
|
+
|
|
7
|
+
DISABLE = False
|
|
8
|
+
|
|
9
|
+
# Some sort of global state is needed here so that we can decorate
|
|
10
|
+
# methods without having to have a Cfg around.
|
|
11
|
+
VERBOSE = not False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@cache
|
|
15
|
+
def _logger():
|
|
16
|
+
return open(f'/tmp/log-{os.getpid()}.txt', 'w')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def log(*a, **ka) -> None:
|
|
20
|
+
print(*a, **ka, file=_logger())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def verbose(*a, **ka) -> None:
|
|
24
|
+
if VERBOSE:
|
|
25
|
+
log(*a, **ka)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def logged(function: C) -> C:
|
|
29
|
+
if DISABLE:
|
|
30
|
+
return function
|
|
31
|
+
|
|
32
|
+
@wraps(function)
|
|
33
|
+
def wrapped(*args, **kwargs):
|
|
34
|
+
verbose(function, 'before')
|
|
35
|
+
try:
|
|
36
|
+
return function(*args, **kwargs)
|
|
37
|
+
except BaseException as e:
|
|
38
|
+
verbose(function, 'exception', type(e), *e.args)
|
|
39
|
+
raise
|
|
40
|
+
finally:
|
|
41
|
+
verbose(function, 'after')
|
|
42
|
+
|
|
43
|
+
return t.cast(C, wrapped)
|
recs/ui/__init__.py
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import multiprocessing as mp
|
|
2
|
+
import typing as t
|
|
3
|
+
from threading import Lock
|
|
4
|
+
|
|
5
|
+
from overrides import override
|
|
6
|
+
from threa import Wrapper
|
|
7
|
+
|
|
8
|
+
from recs.cfg import Cfg, Track
|
|
9
|
+
from recs.ui.device_recorder import DeviceRecorder
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeviceProcess(Wrapper):
|
|
13
|
+
status: str = 'ok'
|
|
14
|
+
sent: bool = False
|
|
15
|
+
|
|
16
|
+
def __init__(self, cfg: Cfg, tracks: t.Sequence[Track]) -> None:
|
|
17
|
+
self.connection, child = mp.Pipe()
|
|
18
|
+
self._lock = Lock()
|
|
19
|
+
kwargs = {'cfg': cfg.cfg, 'connection': child, 'tracks': tracks}
|
|
20
|
+
self.process = mp.Process(target=DeviceRecorder, kwargs=kwargs)
|
|
21
|
+
super().__init__(self.process)
|
|
22
|
+
|
|
23
|
+
self.device_name = tracks[0].device.name
|
|
24
|
+
|
|
25
|
+
def set_sent(self) -> bool:
|
|
26
|
+
with self._lock:
|
|
27
|
+
sent, self.sent = self.sent, True
|
|
28
|
+
return not sent
|
|
29
|
+
|
|
30
|
+
@override
|
|
31
|
+
def finish(self):
|
|
32
|
+
self.running = False
|
|
33
|
+
if self.set_sent():
|
|
34
|
+
self.connection.send(self.status)
|
|
35
|
+
|
|
36
|
+
self.process.join()
|
|
37
|
+
self.finished = True
|
recs/ui/device_recorder.py
CHANGED
|
@@ -1,99 +1,83 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import os
|
|
2
|
+
import traceback
|
|
3
3
|
import typing as t
|
|
4
|
-
from
|
|
4
|
+
from multiprocessing.connection import Connection
|
|
5
5
|
|
|
6
6
|
import numpy as np
|
|
7
|
-
from threa import
|
|
7
|
+
from threa import Runnables, ThreadQueue, Wrapper
|
|
8
8
|
|
|
9
|
-
from recs.
|
|
10
|
-
from recs.base
|
|
9
|
+
from recs.audio.channel_writer import ChannelWriter
|
|
10
|
+
from recs.base import cfg_raw
|
|
11
|
+
from recs.base.types import Format
|
|
11
12
|
from recs.cfg import Cfg, Track
|
|
12
|
-
from recs.
|
|
13
|
+
from recs.cfg.device import Update
|
|
13
14
|
|
|
15
|
+
NEW_CODE_FLAG = 'RECS_NEW_CODE' in os.environ
|
|
16
|
+
FINISH = 'finish'
|
|
14
17
|
OFFLINE_TIME = 1
|
|
18
|
+
POLL_TIMEOUT = 0.05
|
|
15
19
|
|
|
16
20
|
|
|
17
|
-
class DeviceRecorder(
|
|
18
|
-
|
|
19
|
-
from recs.ui.channel_recorder import ChannelRecorder
|
|
20
|
-
|
|
21
|
-
super().__init__()
|
|
22
|
-
self.cfg = cfg
|
|
21
|
+
class DeviceRecorder(Runnables):
|
|
22
|
+
sample_count: int = 0
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
self
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
cfg: cfg_raw.CfgRaw,
|
|
27
|
+
connection: Connection,
|
|
28
|
+
tracks: t.Sequence[Track],
|
|
29
|
+
) -> None:
|
|
30
|
+
self.cfg = Cfg(**cfg.asdict())
|
|
31
|
+
self.connection = connection
|
|
26
32
|
|
|
27
33
|
self.device = d = tracks[0].device
|
|
28
34
|
self.name = self.cfg.aliases.display_name(d)
|
|
29
35
|
self.times = self.cfg.times.scale(d.samplerate)
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
self.
|
|
33
|
-
self.
|
|
37
|
+
cw = (ChannelWriter(cfg=self.cfg, times=self.times, track=t) for t in tracks)
|
|
38
|
+
self.channel_writers = tuple(cw)
|
|
39
|
+
self.queue = ThreadQueue(self.device_callback, name=f'ThreadQueue-{d.name}')
|
|
40
|
+
self.input_stream = self.device.input_stream(
|
|
41
|
+
device_callback=self.queue.put,
|
|
42
|
+
sdtype=self.cfg.sdtype,
|
|
43
|
+
on_error=self.stop,
|
|
44
|
+
)
|
|
45
|
+
super().__init__(Wrapper(self.input_stream), self.queue, *self.channel_writers)
|
|
46
|
+
self.exit: dict[str, str | int] = {}
|
|
47
|
+
|
|
48
|
+
with self:
|
|
49
|
+
while not self.exit:
|
|
50
|
+
try:
|
|
51
|
+
if connection.poll(POLL_TIMEOUT):
|
|
52
|
+
if msg := connection.recv():
|
|
53
|
+
self.queue.put({'reason': msg})
|
|
54
|
+
except KeyboardInterrupt:
|
|
55
|
+
# We should never get here!
|
|
56
|
+
print('Aborted', d.name)
|
|
57
|
+
|
|
58
|
+
self.connection.send({self.device.name: {'_exit': self.exit}})
|
|
59
|
+
|
|
60
|
+
def device_callback(self, update: Update | dict[str, t.Any]) -> None:
|
|
61
|
+
if self.exit:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if isinstance(update, dict):
|
|
65
|
+
self.exit = update
|
|
66
|
+
else:
|
|
67
|
+
try:
|
|
68
|
+
self._device_callback(update)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
self.exit = {'reason': str(e), 'traceback': traceback.format_exc()}
|
|
71
|
+
|
|
72
|
+
def _device_callback(self, u: Update) -> None:
|
|
73
|
+
if self.cfg.format == Format.mp3 and u.array.dtype == np.float32:
|
|
74
|
+
# mp3 and float32 crashes every time on my machine
|
|
75
|
+
u = Update(u.array.astype(np.float64), u.timestamp)
|
|
34
76
|
|
|
35
|
-
|
|
36
|
-
self.timestamp = time
|
|
77
|
+
msgs = {c.track.name: c.update(u) for c in self.channel_writers}
|
|
37
78
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
self.block_size(array.shape[0])
|
|
44
|
-
|
|
45
|
-
if (t := self.times.total_run_time) and (extra := self.block_size.sum - t) >= 0:
|
|
46
|
-
self.stop()
|
|
47
|
-
if array.shape[0] <= extra:
|
|
48
|
-
return
|
|
49
|
-
array = array[slice(extra), :]
|
|
50
|
-
|
|
51
|
-
for c in self.channel_recorders:
|
|
52
|
-
c.callback(array, time)
|
|
53
|
-
|
|
54
|
-
def active(self) -> Active:
|
|
55
|
-
# TODO: this does work but we should probably bypass this
|
|
56
|
-
dt = times.time() - self.timestamp
|
|
57
|
-
return Active.offline if dt > OFFLINE_TIME else Active.active
|
|
58
|
-
|
|
59
|
-
def rows(self) -> t.Iterator[dict[str, t.Any]]:
|
|
60
|
-
active = self.active()
|
|
61
|
-
yield {'device': self.name, 'on': active}
|
|
62
|
-
for v in self.channel_recorders:
|
|
63
|
-
for r in v.rows():
|
|
64
|
-
if active == Active.offline:
|
|
65
|
-
yield r | {'on': active}
|
|
66
|
-
else:
|
|
67
|
-
yield r
|
|
68
|
-
|
|
69
|
-
def stop(self) -> None:
|
|
70
|
-
self.running.clear()
|
|
71
|
-
for c in self.channel_recorders:
|
|
72
|
-
c.stop()
|
|
73
|
-
self.stopped.set()
|
|
74
|
-
|
|
75
|
-
def __enter__(self):
|
|
76
|
-
if self.input_stream:
|
|
77
|
-
self.input_stream.__enter__()
|
|
78
|
-
|
|
79
|
-
def __exit__(self, *a) -> None:
|
|
80
|
-
if input_stream := self.__dict__.get('input_stream'):
|
|
81
|
-
return input_stream.__exit__(*a)
|
|
82
|
-
|
|
83
|
-
@property
|
|
84
|
-
def file_count(self) -> int:
|
|
85
|
-
return sum(c.file_count for c in self.channel_recorders)
|
|
86
|
-
|
|
87
|
-
@property
|
|
88
|
-
def file_size(self) -> int:
|
|
89
|
-
return sum(c.file_size for c in self.channel_recorders)
|
|
90
|
-
|
|
91
|
-
@property
|
|
92
|
-
def recorded_time(self) -> float:
|
|
93
|
-
return sum(c.recorded_time for c in self.channel_recorders)
|
|
94
|
-
|
|
95
|
-
@cached_property
|
|
96
|
-
def input_stream(self) -> t.Iterator[None] | None:
|
|
97
|
-
return self.device.input_stream(
|
|
98
|
-
callback=self.callback, dtype=self.cfg.sdtype or SDTYPE, stop=self.stop
|
|
99
|
-
)
|
|
79
|
+
self.connection.send({self.device.name: msgs})
|
|
80
|
+
|
|
81
|
+
self.sample_count += len(u.array)
|
|
82
|
+
if (t := self.times.total_run_time) and self.sample_count >= t:
|
|
83
|
+
self.exit = {'reason': 'total_run_time', 'samples': self.sample_count}
|
recs/ui/device_tracks.py
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import typing as t
|
|
2
2
|
|
|
3
|
+
from recs.base import RecsError
|
|
3
4
|
from recs.cfg import Cfg, InputDevice, Track
|
|
4
5
|
|
|
5
6
|
DeviceTracks = dict[InputDevice, t.Sequence[Track]]
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def device_tracks(cfg: Cfg) -> DeviceTracks:
|
|
10
|
+
if not cfg.devices:
|
|
11
|
+
raise RecsError('No audio input devices were found')
|
|
12
|
+
|
|
9
13
|
exc = cfg.aliases.to_tracks(cfg.exclude)
|
|
10
14
|
inc = cfg.aliases.to_tracks(cfg.include)
|
|
11
15
|
|
|
12
16
|
it = ((d, list(device_track(d, exc, inc))) for d in cfg.devices.values())
|
|
13
|
-
|
|
17
|
+
ts: DeviceTracks = {d: v for d, v in it if v}
|
|
18
|
+
if ts:
|
|
19
|
+
return ts
|
|
20
|
+
|
|
21
|
+
raise RecsError('No channels selected')
|
|
14
22
|
|
|
15
23
|
|
|
16
24
|
def device_track(
|
recs/ui/full_state.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
|
|
3
|
+
from recs.base import state, times
|
|
4
|
+
from recs.base.types import Active
|
|
5
|
+
from recs.cfg import InputDevice, Track
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FullState:
|
|
9
|
+
def __init__(self, tracks: dict[InputDevice, t.Sequence[Track]]) -> None:
|
|
10
|
+
def device_state(t) -> dict[str, state.ChannelState]:
|
|
11
|
+
return {i.name: state.ChannelState() for i in t}
|
|
12
|
+
|
|
13
|
+
self.state = {k.name: device_state(v) for k, v in tracks.items()}
|
|
14
|
+
self.total = state.ChannelState()
|
|
15
|
+
self.start_time = times.timestamp()
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def elapsed_time(self) -> float:
|
|
19
|
+
return times.timestamp() - self.start_time
|
|
20
|
+
|
|
21
|
+
def update(self, state: dict[str, dict[str, state.ChannelState]]) -> None:
|
|
22
|
+
for device_name, device_state in state.items():
|
|
23
|
+
for channel_name, channel_state in device_state.items():
|
|
24
|
+
self.state[device_name][channel_name] += channel_state
|
|
25
|
+
self.total += channel_state
|
|
26
|
+
if '-' in channel_name:
|
|
27
|
+
# This is a stereo channel, so count it again
|
|
28
|
+
self.total.recorded_time += channel_state.recorded_time
|
|
29
|
+
|
|
30
|
+
def rows(self, devices: t.Sequence[str]) -> t.Iterator[dict[str, t.Any]]:
|
|
31
|
+
yield {
|
|
32
|
+
'time': self.elapsed_time,
|
|
33
|
+
'recorded': self.total.recorded_time,
|
|
34
|
+
'file_size': self.total.file_size,
|
|
35
|
+
'file_count': self.total.file_count,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for device_name, device_state in self.state.items():
|
|
39
|
+
active = Active.active if device_name in devices else Active.offline
|
|
40
|
+
yield {'device': device_name, 'on': active} # TODO: use alias here
|
|
41
|
+
|
|
42
|
+
for c, s in device_state.items():
|
|
43
|
+
yield {
|
|
44
|
+
'channel': c, # TODO: use alias here
|
|
45
|
+
'on': Active.active if s.is_active else Active.inactive,
|
|
46
|
+
'recorded': s.recorded_time,
|
|
47
|
+
'file_size': s.file_size,
|
|
48
|
+
'file_count': s.file_count,
|
|
49
|
+
'volume': len(s.volume) and sum(s.volume) / len(s.volume),
|
|
50
|
+
}
|
recs/ui/live.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import dataclasses as dc
|
|
2
1
|
import typing as t
|
|
3
2
|
from functools import cached_property
|
|
4
3
|
|
|
@@ -6,6 +5,7 @@ from humanfriendly import format_size
|
|
|
6
5
|
from rich import live
|
|
7
6
|
from rich.console import Console
|
|
8
7
|
from rich.table import Table
|
|
8
|
+
from threa import Runnable
|
|
9
9
|
|
|
10
10
|
from recs.base import times
|
|
11
11
|
from recs.base.types import Active
|
|
@@ -18,19 +18,17 @@ RowsFunction = t.Callable[[], t.Iterator[dict[str, t.Any]]]
|
|
|
18
18
|
CONSOLE = Console(color_system='truecolor')
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
class Live:
|
|
23
|
-
rows: RowsFunction
|
|
24
|
-
cfg: Cfg
|
|
25
|
-
|
|
21
|
+
class Live(Runnable):
|
|
26
22
|
_last_update_time: float = 0
|
|
27
23
|
|
|
24
|
+
def __init__(self, rows: RowsFunction, cfg: Cfg) -> None:
|
|
25
|
+
self.rows = rows
|
|
26
|
+
self.cfg = cfg
|
|
27
|
+
super().__init__()
|
|
28
|
+
|
|
28
29
|
def update(self) -> None:
|
|
29
30
|
if not self.cfg.silent:
|
|
30
|
-
|
|
31
|
-
if (t - self._last_update_time) >= 1 / self.cfg.ui_refresh_rate:
|
|
32
|
-
self._last_update_time = t
|
|
33
|
-
self.live.update(self.table())
|
|
31
|
+
self.live.update(self.table())
|
|
34
32
|
|
|
35
33
|
@cached_property
|
|
36
34
|
def live(self) -> live.Live:
|
|
@@ -38,19 +36,21 @@ class Live:
|
|
|
38
36
|
self.table(),
|
|
39
37
|
console=CONSOLE,
|
|
40
38
|
refresh_per_second=self.cfg.ui_refresh_rate,
|
|
41
|
-
transient=
|
|
39
|
+
transient=self.cfg.clear,
|
|
42
40
|
)
|
|
43
41
|
|
|
44
42
|
def table(self) -> Table:
|
|
45
43
|
return TABLE_FORMATTER(self.rows())
|
|
46
44
|
|
|
47
|
-
def
|
|
45
|
+
def start(self) -> None:
|
|
46
|
+
super().start()
|
|
48
47
|
if not self.cfg.silent:
|
|
49
|
-
self.live.
|
|
48
|
+
self.live.start(refresh=True)
|
|
50
49
|
|
|
51
|
-
def
|
|
50
|
+
def stop(self) -> None:
|
|
52
51
|
if not self.cfg.silent:
|
|
53
|
-
self.live.
|
|
52
|
+
self.live.stop()
|
|
53
|
+
super().stop()
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def _rgb(r=0, g=0, b=0) -> str:
|
|
@@ -94,7 +94,19 @@ def _time_to_str(x) -> str:
|
|
|
94
94
|
|
|
95
95
|
|
|
96
96
|
def _naturalsize(x: int) -> str:
|
|
97
|
-
|
|
97
|
+
if not x:
|
|
98
|
+
return ''
|
|
99
|
+
|
|
100
|
+
fs = format_size(x)
|
|
101
|
+
|
|
102
|
+
# Fix #97
|
|
103
|
+
value, _, unit = fs.partition(' ')
|
|
104
|
+
if unit != 'bytes':
|
|
105
|
+
integer, _, decimal = value.partition('.')
|
|
106
|
+
decimal = (decimal + '00')[:2]
|
|
107
|
+
fs = f'{integer}.{decimal} {unit}'
|
|
108
|
+
|
|
109
|
+
return f'{fs:>9}'
|
|
98
110
|
|
|
99
111
|
|
|
100
112
|
def _channel(x: str) -> str:
|