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/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.channels_name = f'{a}' if a == b else f'{a}-{b}'
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.channels_name = ''
28
+ self.name = ''
29
29
 
30
30
  def __str__(self) -> str:
31
31
  if self.channels:
32
- return f'{self.device.name} + {self.channels_name}'
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
@@ -0,0 +1,10 @@
1
+ import typing as t
2
+ from contextlib import ExitStack, contextmanager
3
+
4
+
5
+ @contextmanager
6
+ def contexts(*contexts: t.ContextManager) -> t.Generator:
7
+ with ExitStack() as stack:
8
+ for c in contexts:
9
+ stack.enter_context(c)
10
+ yield
recs/misc/counter.py CHANGED
@@ -51,20 +51,21 @@ class Accumulator:
51
51
 
52
52
 
53
53
  class MovingBlock:
54
- _deque: deque[Num] | None = None
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 not self._deque:
60
+ if self._dq is None:
61
61
  maxlen = int(0.5 + self.moving_average_time / len(b))
62
- self._deque = deque((), maxlen)
62
+ self._dq = deque((), maxlen)
63
63
 
64
- self._deque.append(b.volume)
64
+ self._dq.append(b.amplitude)
65
65
 
66
- def mean(self) -> Num:
67
- if not self._deque:
68
- return 0
66
+ def mean(self) -> np.ndarray:
67
+ if not self._dq:
68
+ return np.array([0])
69
69
 
70
- return sum(self._deque) / len(self._deque)
70
+ it = (d for i, d in enumerate(self._dq) if i)
71
+ return sum(it, start=self._dq[0]) / len(self._dq)
@@ -1,6 +1,6 @@
1
1
  import string
2
2
 
3
- PUNCTUATION = ' ()+,-.=[]_'
3
+ PUNCTUATION = ' ()+,-.=[]_/'
4
4
  CHARS = set(string.ascii_letters + string.digits + PUNCTUATION)
5
5
 
6
6
 
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
@@ -1,99 +1,83 @@
1
- from __future__ import annotations
2
-
1
+ import os
2
+ import traceback
3
3
  import typing as t
4
- from functools import cached_property, partial
4
+ from multiprocessing.connection import Connection
5
5
 
6
6
  import numpy as np
7
- from threa import Runnable
7
+ from threa import Runnables, ThreadQueue, Wrapper
8
8
 
9
- from recs.base import times
10
- from recs.base.types import SDTYPE, Active, Format
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.misc.counter import Accumulator, Counter
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(Runnable):
18
- def __init__(self, cfg: Cfg, tracks: t.Sequence[Track]) -> None:
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
- self.block_count = Counter()
25
- self.block_size = Accumulator()
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
- make = partial(ChannelRecorder, cfg=cfg, times=self.times)
32
- self.channel_recorders = tuple(make(track=t) for t in tracks)
33
- self.timestamp = times.time()
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
- def callback(self, array: np.ndarray, time: float) -> None:
36
- self.timestamp = time
77
+ msgs = {c.track.name: c.update(u) for c in self.channel_writers}
37
78
 
38
- if self.cfg.format == Format.mp3 and array.dtype == np.float32:
39
- # mp3 and float32 crashes every time on my machine
40
- array = array.astype(np.float64)
41
-
42
- self.block_count()
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
- return {d: v for d, v in it if v}
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
- @dc.dataclass
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
- t = times.time()
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=not self.cfg.retain,
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 __enter__(self) -> None:
45
+ def start(self) -> None:
46
+ super().start()
48
47
  if not self.cfg.silent:
49
- self.live.__enter__()
48
+ self.live.start(refresh=True)
50
49
 
51
- def __exit__(self, *a) -> None:
50
+ def stop(self) -> None:
52
51
  if not self.cfg.silent:
53
- self.live.__exit__(*a)
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
- return f'{format_size(x):>9}' if x else ''
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: