litoid 0.1.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.
- litoid-0.1.0/LICENSE +21 -0
- litoid-0.1.0/PKG-INFO +35 -0
- litoid-0.1.0/README.md +2 -0
- litoid-0.1.0/litoid/__init__.py +0 -0
- litoid-0.1.0/litoid/__main__.py +19 -0
- litoid-0.1.0/litoid/app.py +9 -0
- litoid-0.1.0/litoid/io/__init__.py +0 -0
- litoid-0.1.0/litoid/io/dmx.py +19 -0
- litoid-0.1.0/litoid/io/hotkey.py +29 -0
- litoid-0.1.0/litoid/io/key_mouse.py +26 -0
- litoid-0.1.0/litoid/io/midi/__init__.py +3 -0
- litoid-0.1.0/litoid/io/midi/message.py +219 -0
- litoid-0.1.0/litoid/io/midi/midi.py +61 -0
- litoid-0.1.0/litoid/io/osc.py +41 -0
- litoid-0.1.0/litoid/io/player.py +64 -0
- litoid-0.1.0/litoid/io/recorder.py +93 -0
- litoid-0.1.0/litoid/io/track.py +66 -0
- litoid-0.1.0/litoid/litoid.py +33 -0
- litoid-0.1.0/litoid/log.py +53 -0
- litoid-0.1.0/litoid/scenes/__init__.py +0 -0
- litoid-0.1.0/litoid/scenes/compose.py +18 -0
- litoid-0.1.0/litoid/scenes/rgb_keyboard.py +24 -0
- litoid-0.1.0/litoid/state/__init__.py +0 -0
- litoid-0.1.0/litoid/state/instrument.py +86 -0
- litoid-0.1.0/litoid/state/instruments.py +40 -0
- litoid-0.1.0/litoid/state/lamp.py +67 -0
- litoid-0.1.0/litoid/state/level.py +16 -0
- litoid-0.1.0/litoid/state/scene.py +34 -0
- litoid-0.1.0/litoid/state/state.py +102 -0
- litoid-0.1.0/litoid/ui/__init__.py +0 -0
- litoid-0.1.0/litoid/ui/action.py +103 -0
- litoid-0.1.0/litoid/ui/canvas_window.py +22 -0
- litoid-0.1.0/litoid/ui/controller.py +110 -0
- litoid-0.1.0/litoid/ui/defaults.py +11 -0
- litoid-0.1.0/litoid/ui/drawing_canvas.py +44 -0
- litoid-0.1.0/litoid/ui/event.py +34 -0
- litoid-0.1.0/litoid/ui/layout.py +72 -0
- litoid-0.1.0/litoid/ui/model.py +96 -0
- litoid-0.1.0/litoid/ui/ui.py +71 -0
- litoid-0.1.0/litoid/ui/view.py +66 -0
- litoid-0.1.0/litoid/util/__init__.py +0 -0
- litoid-0.1.0/litoid/util/file.py +22 -0
- litoid-0.1.0/litoid/util/has_thread.py +38 -0
- litoid-0.1.0/litoid/util/is_running.py +25 -0
- litoid-0.1.0/litoid/util/play.py +25 -0
- litoid-0.1.0/litoid/util/smooth.py +19 -0
- litoid-0.1.0/litoid/util/thread_queue.py +28 -0
- litoid-0.1.0/litoid/util/timed_heap.py +45 -0
- litoid-0.1.0/pyproject.toml +38 -0
litoid-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Tom Ritchford
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
litoid-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: litoid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 💡Sequence DMX lighting 💡
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Tom Ritchford
|
|
7
|
+
Author-email: tom@swirly.com
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Requires-Dist: clsprop (>=1.1.0)
|
|
13
|
+
Requires-Dist: datacls (>=4.6.0)
|
|
14
|
+
Requires-Dist: distinctipy (>=1.2.2)
|
|
15
|
+
Requires-Dist: dtyper (>=2.1.0)
|
|
16
|
+
Requires-Dist: ipython (>=8.12.0)
|
|
17
|
+
Requires-Dist: matplotlib (>=3.7.1)
|
|
18
|
+
Requires-Dist: mido (>=1.2.10)
|
|
19
|
+
Requires-Dist: numpy (>=1.24.2)
|
|
20
|
+
Requires-Dist: psgdemos (>=1.12.1)
|
|
21
|
+
Requires-Dist: pyenttec (>=1.4)
|
|
22
|
+
Requires-Dist: pynput (>=1.7.6)
|
|
23
|
+
Requires-Dist: pyperclip (>=1.8.2)
|
|
24
|
+
Requires-Dist: pysimplegui (>=4.60.4)
|
|
25
|
+
Requires-Dist: python-osc (>=1.8.1)
|
|
26
|
+
Requires-Dist: python-rtmidi (>=1.5.4)
|
|
27
|
+
Requires-Dist: simpleaudio (>=1.0.4)
|
|
28
|
+
Requires-Dist: tomlkit (>=0.11.6)
|
|
29
|
+
Requires-Dist: typer (>=0.7.0)
|
|
30
|
+
Requires-Dist: xmod (>=1.4.0)
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# litoid
|
|
34
|
+
Sequence DMX lighting
|
|
35
|
+
|
litoid-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
def cli():
|
|
2
|
+
from . import app, litoid
|
|
3
|
+
|
|
4
|
+
assert litoid
|
|
5
|
+
app.app(standalone_mode=False)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def gui():
|
|
9
|
+
from .ui.controller import Controller
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
Controller().start()
|
|
14
|
+
finally:
|
|
15
|
+
time.sleep(0.1) # Allow daemon threads to print tracebacks
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == '__main__':
|
|
19
|
+
gui()
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from functools import cached_property
|
|
2
|
+
import datacls
|
|
3
|
+
import pyenttec
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@datacls.mutable
|
|
7
|
+
class DMX:
|
|
8
|
+
port: str
|
|
9
|
+
|
|
10
|
+
@cached_property
|
|
11
|
+
def connection(self):
|
|
12
|
+
return pyenttec.DMXConnection(self.port)
|
|
13
|
+
|
|
14
|
+
@cached_property
|
|
15
|
+
def frame(self):
|
|
16
|
+
return memoryview(self.connection.dmx_frame)
|
|
17
|
+
|
|
18
|
+
def send_packet(self):
|
|
19
|
+
self.connection.render()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from ..util.has_thread import HasThread
|
|
2
|
+
from functools import cached_property, partial
|
|
3
|
+
from pynput import keyboard
|
|
4
|
+
from typing import Callable
|
|
5
|
+
import datacls
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@datacls.mutable
|
|
9
|
+
class HotKeys(HasThread):
|
|
10
|
+
keys: dict[str, str] = datacls.field(dict)
|
|
11
|
+
callback: Callable = print
|
|
12
|
+
|
|
13
|
+
@cached_property
|
|
14
|
+
def hotkeys(self):
|
|
15
|
+
def cmd(k):
|
|
16
|
+
return k if '<' in k else f'<cmd>+{k}'
|
|
17
|
+
|
|
18
|
+
k = {cmd(k): partial(self.callback, v) for k, v in self.keys.items()}
|
|
19
|
+
return keyboard.GlobalHotKeys(k)
|
|
20
|
+
|
|
21
|
+
def _target(self):
|
|
22
|
+
with self.hotkeys as h:
|
|
23
|
+
h.join()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == '__main__':
|
|
27
|
+
s = 'abcdefghijklmnopqrstuvwxyz'
|
|
28
|
+
keys = [f'<ctrl>+{i}' for i in s]
|
|
29
|
+
HotKeys(keys)._target()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from ..util.has_thread import HasThread
|
|
2
|
+
from typing import Callable
|
|
3
|
+
import datacls
|
|
4
|
+
import pynput
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@datacls
|
|
8
|
+
class _Base(HasThread):
|
|
9
|
+
callback: Callable = print
|
|
10
|
+
|
|
11
|
+
def _target(self):
|
|
12
|
+
with self.event_module.Events() as events:
|
|
13
|
+
for msg in events:
|
|
14
|
+
self.callback(msg)
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def Message(cls) -> type:
|
|
18
|
+
return cls.event_module.Events.Event
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Keyboard(_Base):
|
|
22
|
+
event_module = pynput.keyboard
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Mouse(_Base):
|
|
26
|
+
event_module = pynput.mouse
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import clsprop
|
|
2
|
+
import xmod
|
|
3
|
+
|
|
4
|
+
_PITCH = 0xe0
|
|
5
|
+
_SYSEX = 0xF0
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@xmod
|
|
9
|
+
class MidiMessage:
|
|
10
|
+
fields = ()
|
|
11
|
+
status_start = 0
|
|
12
|
+
|
|
13
|
+
__slots__ = 'data', 'time'
|
|
14
|
+
|
|
15
|
+
def __init__(self, data=None, time=0, **kwargs):
|
|
16
|
+
self.time = time
|
|
17
|
+
|
|
18
|
+
if data is not None:
|
|
19
|
+
self.data = data
|
|
20
|
+
else:
|
|
21
|
+
self.data = (
|
|
22
|
+
[self.status_start]
|
|
23
|
+
+ [kwargs.pop(f, 0) or 0 for f in self.fields]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if bad := [k for k, v in kwargs.items() if v is not None]:
|
|
27
|
+
assert (data is None) or not kwargs, f'{data=} {kwargs=}'
|
|
28
|
+
s = 's' * (len(bad) != 1)
|
|
29
|
+
raise ValueError(f'Extra parameter{s}: {list(kwargs)}')
|
|
30
|
+
|
|
31
|
+
def __len__(self):
|
|
32
|
+
return len(self.data)
|
|
33
|
+
|
|
34
|
+
def __getitem__(self, i):
|
|
35
|
+
return self.data[i]
|
|
36
|
+
|
|
37
|
+
@clsprop
|
|
38
|
+
def has_channel(cls) -> bool:
|
|
39
|
+
return _has_channel(cls.status_start)
|
|
40
|
+
|
|
41
|
+
@clsprop
|
|
42
|
+
def size(cls) -> int:
|
|
43
|
+
return (
|
|
44
|
+
len(cls.fields)
|
|
45
|
+
+ (not cls.has_channel)
|
|
46
|
+
+ (cls.status_start == _PITCH)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def status(self):
|
|
51
|
+
return self.data[0]
|
|
52
|
+
|
|
53
|
+
def asdict(self):
|
|
54
|
+
return {k: getattr(self, k) for k in self.fields}
|
|
55
|
+
|
|
56
|
+
def __new__(cls, data=None, time=0, **kwargs):
|
|
57
|
+
if data is not None and kwargs:
|
|
58
|
+
raise ValueError('Cannot specify data and named args together')
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
cls = MESSAGE_CLASSES[data[0] - 128]
|
|
62
|
+
except IndexError:
|
|
63
|
+
cls = None
|
|
64
|
+
|
|
65
|
+
if cls is None:
|
|
66
|
+
raise ValueError(f'{data=} is not a MIDI packet')
|
|
67
|
+
|
|
68
|
+
if cls.status_start != _SYSEX and len(data) != cls.size:
|
|
69
|
+
msg = f'Wrong MIDI packet length: {len(data)} != {cls.size}'
|
|
70
|
+
raise ValueError(msg)
|
|
71
|
+
|
|
72
|
+
self = super().__new__(cls)
|
|
73
|
+
cls.__init__(self, data, time, **kwargs)
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MidiChannelMessage(MidiMessage):
|
|
78
|
+
def __init__(self, data=None, time=0, **kwargs):
|
|
79
|
+
channel = kwargs.pop('channel', None)
|
|
80
|
+
|
|
81
|
+
if is_pitch := self.status_start == _PITCH:
|
|
82
|
+
pitch = kwargs.pop('pitch', 0)
|
|
83
|
+
super().__init__(data, time, **kwargs)
|
|
84
|
+
if is_pitch:
|
|
85
|
+
self.pitch = pitch
|
|
86
|
+
if channel is not None:
|
|
87
|
+
self.channel = channel
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def channel(self):
|
|
91
|
+
return self.status & 0x0F
|
|
92
|
+
|
|
93
|
+
@channel.setter
|
|
94
|
+
def channel(self, channel):
|
|
95
|
+
self.status = (self.status & 0xF0) | channel
|
|
96
|
+
|
|
97
|
+
def asdict(self):
|
|
98
|
+
return super().asdict() | {'channel': self.channel}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _has_channel(status: int) -> bool:
|
|
102
|
+
return status < _SYSEX
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _class(status_start, name, *props):
|
|
106
|
+
if _has_channel(status_start):
|
|
107
|
+
parent = MidiChannelMessage
|
|
108
|
+
if status_start == _PITCH:
|
|
109
|
+
fields = 'pitch',
|
|
110
|
+
|
|
111
|
+
fields = 'channel', *props
|
|
112
|
+
else:
|
|
113
|
+
parent = MidiMessage
|
|
114
|
+
fields = props
|
|
115
|
+
|
|
116
|
+
members = {
|
|
117
|
+
'__init__': _init(parent, fields),
|
|
118
|
+
'__repr__': _repr(fields),
|
|
119
|
+
'fields': fields,
|
|
120
|
+
'status_start': status_start,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
props = {prop: _prop(i + 1, prop) for i, prop in enumerate(props)}
|
|
124
|
+
cls = type(name, (parent,), members | _pitch(status_start) | props)
|
|
125
|
+
globals()[name] = cls
|
|
126
|
+
return cls
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _prop(i, field):
|
|
130
|
+
def getter(self):
|
|
131
|
+
return self.data[i]
|
|
132
|
+
|
|
133
|
+
def setter(self, d):
|
|
134
|
+
self.data[i] = d
|
|
135
|
+
|
|
136
|
+
getter.__name__ = setter.__name__ = field
|
|
137
|
+
return property(getter, setter)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _init(parent, fields):
|
|
141
|
+
# Construct the constructor!
|
|
142
|
+
if params_in := ', '.join(f'{f}=None' for f in fields):
|
|
143
|
+
params_in = f', *, {params_in}'
|
|
144
|
+
|
|
145
|
+
if params_out := ', '.join(f'{f}={f}' for f in fields):
|
|
146
|
+
params_out = f', {params_out}'
|
|
147
|
+
|
|
148
|
+
definition = f'lambda self, data=None, time=0{params_in}:'
|
|
149
|
+
result = f'{parent.__name__}.__init__(self, data, time{params_out})'
|
|
150
|
+
init = eval(f'{definition} {result}')
|
|
151
|
+
init.__name__ = '__init__'
|
|
152
|
+
return init
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _pitch(status_start):
|
|
156
|
+
if status_start != _PITCH:
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
pitch_offset = 0x2000
|
|
160
|
+
|
|
161
|
+
def getter(self):
|
|
162
|
+
return 0x80 * self.pitch_hsb + self.pitch_lsb - self.pitch_offset
|
|
163
|
+
|
|
164
|
+
def setter(self, p):
|
|
165
|
+
self.pitch_hsb, self.pitch_lsb = divmod(p + self.pitch_offset, 0x80)
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
'pitch': property(getter, setter),
|
|
169
|
+
'pitch_offset': pitch_offset,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _repr(fields):
|
|
174
|
+
# See dataclasses.py around line 595
|
|
175
|
+
fields = *fields, 'time'
|
|
176
|
+
params = ', '.join(f'{f}={{self.{f}!r}}' for f in fields)
|
|
177
|
+
classname = '{self.__class__.__qualname__}'
|
|
178
|
+
rep = eval(f'lambda self: f"{classname}({params})"')
|
|
179
|
+
rep.__name__ = '__repr__'
|
|
180
|
+
return rep
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_CHANNEL = (
|
|
184
|
+
# Channel messages
|
|
185
|
+
_class(0x80, 'NoteOff', 'note', 'velocity'),
|
|
186
|
+
_class(0x90, 'NoteOn', 'note', 'velocity'),
|
|
187
|
+
_class(0xa0, 'Polytouch', 'note', 'value'),
|
|
188
|
+
_class(0xb0, 'ControlChange', 'control', 'value'),
|
|
189
|
+
_class(0xc0, 'ProgramChange', 'program'),
|
|
190
|
+
_class(0xd0, 'Aftertouch', 'value'),
|
|
191
|
+
_class(_PITCH, 'Pitch', 'pitch_lsb', 'pitch_hsb'),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
_SYSTEM = (
|
|
195
|
+
# System common messages.
|
|
196
|
+
_class(_SYSEX, 'Sysex'),
|
|
197
|
+
_class(0xf1, 'QuarterFrame', 'frame_type', 'frame_value'),
|
|
198
|
+
_class(0xf2, 'Songpos', 'pos'),
|
|
199
|
+
_class(0xf3, 'SongSelect', 'song'),
|
|
200
|
+
|
|
201
|
+
None, # 0xf4
|
|
202
|
+
None, # 0xf5
|
|
203
|
+
_class(0xf6, 'TuneRequest'),
|
|
204
|
+
None, # 0xf7 is sysex end
|
|
205
|
+
|
|
206
|
+
# System real time messages.
|
|
207
|
+
_class(0xf8, 'Clock'),
|
|
208
|
+
None, # 0xf9
|
|
209
|
+
_class(0xfa, 'Start'),
|
|
210
|
+
_class(0xfb, 'Continue'),
|
|
211
|
+
|
|
212
|
+
_class(0xfc, 'Stop'),
|
|
213
|
+
None, # 0xfd
|
|
214
|
+
_class(0xfe, 'ActiveSensing'),
|
|
215
|
+
_class(0xff, 'Reset'),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
MESSAGE_CLASSES = tuple(c for c in _CHANNEL for i in range(16)) + _SYSTEM
|
|
219
|
+
__all__ = ('MidiMessage',) + tuple(c.__name__ for c in _CHANNEL + _SYSTEM if c)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from .message import MidiMessage
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
from litoid.util.has_thread import HasThread
|
|
4
|
+
from rtmidi import midiutil, MidiIn
|
|
5
|
+
from typing import Callable
|
|
6
|
+
import datacls
|
|
7
|
+
import mido
|
|
8
|
+
import time
|
|
9
|
+
SPIN_TIME = 0.003
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@datacls
|
|
13
|
+
class MidiOutput:
|
|
14
|
+
name: str | None = None
|
|
15
|
+
|
|
16
|
+
@cached_property
|
|
17
|
+
def output_name(self):
|
|
18
|
+
return self.name or sorted(mido.get_output_names()[0])
|
|
19
|
+
|
|
20
|
+
@cached_property
|
|
21
|
+
def output(self):
|
|
22
|
+
return mido.open_output(self.name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@datacls.mutable
|
|
26
|
+
class MidiInput(HasThread):
|
|
27
|
+
callback: Callable = print
|
|
28
|
+
make_message: bool = True
|
|
29
|
+
name: str = ''
|
|
30
|
+
last_event_time: float = 0
|
|
31
|
+
|
|
32
|
+
def _target(self):
|
|
33
|
+
midiin, port_name = midiutil.open_midiinput(self.input_name)
|
|
34
|
+
try:
|
|
35
|
+
while True:
|
|
36
|
+
while not (msg := midiin.get_message()):
|
|
37
|
+
time.sleep(SPIN_TIME)
|
|
38
|
+
|
|
39
|
+
mbytes, deltatime = msg
|
|
40
|
+
if self.last_event_time:
|
|
41
|
+
self.last_event_time += deltatime
|
|
42
|
+
else:
|
|
43
|
+
# Ignore the first deltatime, which seems to be 0 anyway
|
|
44
|
+
self.last_event_time = time.time()
|
|
45
|
+
|
|
46
|
+
if self.make_message:
|
|
47
|
+
msg = MidiMessage(mbytes, time=self.last_event_time)
|
|
48
|
+
self.callback(msg)
|
|
49
|
+
finally:
|
|
50
|
+
midiin.close_port()
|
|
51
|
+
|
|
52
|
+
@cached_property
|
|
53
|
+
def midiin(self):
|
|
54
|
+
return MidiIn(midiutil.get_api_from_environment())
|
|
55
|
+
|
|
56
|
+
def get_input_names(self):
|
|
57
|
+
return self.midiin.get_ports()
|
|
58
|
+
|
|
59
|
+
@cached_property
|
|
60
|
+
def input_name(self):
|
|
61
|
+
return self.name or sorted(self.get_input_names())[0]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from ..util.thread_queue import ThreadQueue
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
from pythonosc.dispatcher import Dispatcher
|
|
4
|
+
from pythonosc.osc_server import BlockingOSCUDPServer
|
|
5
|
+
from typing import Callable
|
|
6
|
+
import datacls
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@datacls.mutable
|
|
10
|
+
class Desc:
|
|
11
|
+
endpoints: tuple[str] = ()
|
|
12
|
+
ip: str = '127.0.0.1'
|
|
13
|
+
port: int = 5005
|
|
14
|
+
maxsize: int = 0
|
|
15
|
+
thread_count: int = 1
|
|
16
|
+
|
|
17
|
+
def server(self):
|
|
18
|
+
return BlockingOSCUDPServer(self.ip, self.port, self.dispatcher)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Message(tuple):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@datacls.mutable
|
|
26
|
+
class Server(Desc, ThreadQueue):
|
|
27
|
+
callback: Callable = print
|
|
28
|
+
|
|
29
|
+
def serve(self):
|
|
30
|
+
self.start()
|
|
31
|
+
self.desc.server().serve_forever()
|
|
32
|
+
|
|
33
|
+
@cached_property
|
|
34
|
+
def dispatcher(self):
|
|
35
|
+
d = Dispatcher()
|
|
36
|
+
for e in self.endpoints:
|
|
37
|
+
d.map(f'/{e}', self.osc_callback)
|
|
38
|
+
return d
|
|
39
|
+
|
|
40
|
+
def osc_callback(self, address, *osc_args):
|
|
41
|
+
self.put(Message(address, *osc_args))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from .recorder import Recorder
|
|
2
|
+
from .track import Track
|
|
3
|
+
from functools import cached_property, total_ordering
|
|
4
|
+
from litoid.util.timed_heap import TimedHeap
|
|
5
|
+
from typing import Callable
|
|
6
|
+
import datacls
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
INFINITE = float('inf')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@datacls.mutable
|
|
13
|
+
class Player:
|
|
14
|
+
recorder: Recorder
|
|
15
|
+
callback: Callable = print
|
|
16
|
+
offset_time: float = 0
|
|
17
|
+
start_time: float = 0
|
|
18
|
+
|
|
19
|
+
@cached_property
|
|
20
|
+
def timed_heap(self) -> TimedHeap:
|
|
21
|
+
kt = ((k, t) for k, t in self.record.tracks.items() if t)
|
|
22
|
+
players = (TrackPlayer(self, k, t) for k, t in kt)
|
|
23
|
+
return TimedHeap([tp for tp in players if tp])
|
|
24
|
+
|
|
25
|
+
def start(self):
|
|
26
|
+
self.start_time = time.time()
|
|
27
|
+
self.timed_heap.start()
|
|
28
|
+
|
|
29
|
+
@cached_property
|
|
30
|
+
def net_offset(self) -> float:
|
|
31
|
+
return self.offset_time + self.start_time - self.recorder.start_time
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@total_ordering
|
|
35
|
+
@datacls.mutable(order=False)
|
|
36
|
+
class TrackPlayer:
|
|
37
|
+
player: Player
|
|
38
|
+
key: tuple[int, ...]
|
|
39
|
+
track: Track
|
|
40
|
+
position: int = 0
|
|
41
|
+
|
|
42
|
+
def __len__(self):
|
|
43
|
+
return len(self.track) - self.position
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def timestamp(self) -> float:
|
|
47
|
+
if not self:
|
|
48
|
+
return INFINITE
|
|
49
|
+
return self._timestamp() + self.player.net_offset
|
|
50
|
+
|
|
51
|
+
def __lt__(self, x) -> bool:
|
|
52
|
+
return self._timestamp() < x._timestamp()
|
|
53
|
+
|
|
54
|
+
def _timestamp(self):
|
|
55
|
+
return self.track.times[self.position]
|
|
56
|
+
|
|
57
|
+
def __call__(self):
|
|
58
|
+
msg_data = self.track.get_message(self.position)
|
|
59
|
+
self.position += 1
|
|
60
|
+
|
|
61
|
+
self.player.callback(self.key, msg_data)
|
|
62
|
+
|
|
63
|
+
if self:
|
|
64
|
+
self.player.timed_heap.push(self)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from .track import Track
|
|
2
|
+
from litoid import log
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import datacls
|
|
5
|
+
import numpy as np
|
|
6
|
+
import time as _time
|
|
7
|
+
|
|
8
|
+
SEP = '-'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@datacls.mutable
|
|
12
|
+
class Recorder:
|
|
13
|
+
"""
|
|
14
|
+
A Recorder record and timestamps messages which are sequences of bytes,
|
|
15
|
+
like MIDI or DMX.
|
|
16
|
+
|
|
17
|
+
The first one or more bytes are used as a key into a dict of Tracks,
|
|
18
|
+
and the remaining bytes are stored in a `uint8` array, and the timestamps
|
|
19
|
+
in a parallel `float64` array
|
|
20
|
+
|
|
21
|
+
This only works for protocols where you can deduce the length of the packet
|
|
22
|
+
from the initial bytes only.
|
|
23
|
+
"""
|
|
24
|
+
name: str
|
|
25
|
+
path: Path | None = None
|
|
26
|
+
tracks: dict = datacls.field(dict[str, Track])
|
|
27
|
+
start_time: float = datacls.field(_time.time)
|
|
28
|
+
update_time: float = datacls.field(_time.time)
|
|
29
|
+
|
|
30
|
+
def __post_init__(self):
|
|
31
|
+
if self.path and self.path.exists():
|
|
32
|
+
self._fill_from_dict(np.load(self.path))
|
|
33
|
+
log.debug(f'Loaded {self.name}', self.report())
|
|
34
|
+
|
|
35
|
+
def record(self, data: list, key_size: int, time: float = 0):
|
|
36
|
+
time = time or _time.time()
|
|
37
|
+
key = SEP.join(str(i) for i in data[:key_size])
|
|
38
|
+
|
|
39
|
+
byte_width = len(data) - key_size
|
|
40
|
+
if (track := self.tracks.get(key)) is None:
|
|
41
|
+
track = Track(byte_width)
|
|
42
|
+
self.tracks[key] = track
|
|
43
|
+
else:
|
|
44
|
+
assert track.byte_width == byte_width
|
|
45
|
+
|
|
46
|
+
track.append(data[key_size:], time)
|
|
47
|
+
self.update_time = time
|
|
48
|
+
|
|
49
|
+
if empty := sorted(k for k, v in self.tracks.items() if not v.empty):
|
|
50
|
+
log.error('Empty tracks', *empty)
|
|
51
|
+
|
|
52
|
+
def save(self):
|
|
53
|
+
if self.path:
|
|
54
|
+
np.savez(self.path, **self.asdict())
|
|
55
|
+
log.debug(f'Saved {self.name}', self.report())
|
|
56
|
+
|
|
57
|
+
def report(self):
|
|
58
|
+
return {
|
|
59
|
+
'event_count': {k: t.count for k, t in self.tracks.items()},
|
|
60
|
+
'total_event_count': sum(t.count for t in self.tracks.values()),
|
|
61
|
+
'track_count': len(self.tracks),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def plottable(self):
|
|
65
|
+
return [i for t in self.tracks.values() for i in t.astuple()]
|
|
66
|
+
|
|
67
|
+
def asdict(self):
|
|
68
|
+
data = {'times': np.array((self.start_time, self.update_time))}
|
|
69
|
+
|
|
70
|
+
for key, track in sorted(self.tracks.items()):
|
|
71
|
+
for name, array in track.asdict().items():
|
|
72
|
+
if len(array):
|
|
73
|
+
joined_key = SEP.join((key, name))
|
|
74
|
+
data[joined_key] = array
|
|
75
|
+
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def fromdict(cls, d):
|
|
80
|
+
c = cls()
|
|
81
|
+
c._fill_from_dict(d)
|
|
82
|
+
return c
|
|
83
|
+
|
|
84
|
+
def _fill_from_dict(self, d):
|
|
85
|
+
parts = {}
|
|
86
|
+
for joined_key, array in d.items():
|
|
87
|
+
if joined_key == 'times':
|
|
88
|
+
self.start_time, self.update_time = array
|
|
89
|
+
else:
|
|
90
|
+
key, _, name = joined_key.rpartition(SEP)
|
|
91
|
+
parts.setdefault(key, {})[name] = array
|
|
92
|
+
|
|
93
|
+
self.tracks = {k: Track.fromdict(**v) for k, v in parts.items()}
|