timetoalign 0.0.1__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.
- timetoalign-0.0.1.dist-info/METADATA +11 -0
- timetoalign-0.0.1.dist-info/RECORD +13 -0
- timetoalign-0.0.1.dist-info/WHEEL +5 -0
- timetoalign-0.0.1.dist-info/top_level.txt +1 -0
- tta/__init__.py +1 -0
- tta/common.py +58 -0
- tta/conversions.py +3777 -0
- tta/events.py +1527 -0
- tta/parsing.py +2236 -0
- tta/registry.py +546 -0
- tta/representations.py +826 -0
- tta/timelines.py +2192 -0
- tta/utils.py +963 -0
tta/utils.py
ADDED
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import itertools
|
|
5
|
+
import logging
|
|
6
|
+
import os.path as osp
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from enum import Enum, StrEnum, auto
|
|
9
|
+
from fractions import Fraction
|
|
10
|
+
from functools import cache
|
|
11
|
+
from numbers import Number
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import (
|
|
14
|
+
TYPE_CHECKING,
|
|
15
|
+
Any,
|
|
16
|
+
Generic,
|
|
17
|
+
Iterable,
|
|
18
|
+
Iterator,
|
|
19
|
+
Literal,
|
|
20
|
+
Optional,
|
|
21
|
+
Type,
|
|
22
|
+
TypeVar,
|
|
23
|
+
overload,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import pandas as pd
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from processing.tta.conversions import Coordinate
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# region NamedSources
|
|
35
|
+
|
|
36
|
+
S = TypeVar("S")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NamedSources(Generic[S]):
|
|
40
|
+
"""Helper class that stores a particular type of things in a ._sources dictionary.
|
|
41
|
+
The specialty is that you can optionally name things but still can retrieve each thing
|
|
42
|
+
by its order of addition, whether it was named or not.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, **kwargs):
|
|
46
|
+
self._sources: dict[int | str, S] = {}
|
|
47
|
+
super().__init__(**kwargs)
|
|
48
|
+
|
|
49
|
+
def _get_next_consecutive_integer(self) -> int:
|
|
50
|
+
return len(self._sources)
|
|
51
|
+
|
|
52
|
+
def _adapt_source(self, source: Any) -> S:
|
|
53
|
+
return source
|
|
54
|
+
|
|
55
|
+
def _add_source(self, source: S, name: Optional[str] = None) -> int | str:
|
|
56
|
+
"""Adds a 2d-array where one axis has shape 2, representing numerators and denominators of
|
|
57
|
+
a sequence of fractions.
|
|
58
|
+
If you assign a name you can retrieve it under that name, otherwise by the source's "name"
|
|
59
|
+
attribute or, if unset or undefined, by the next consecutive integer that is not already
|
|
60
|
+
used as a name.
|
|
61
|
+
"""
|
|
62
|
+
source = self._adapt_source(source)
|
|
63
|
+
self.validate_source(source)
|
|
64
|
+
if name is None:
|
|
65
|
+
name = getattr(source, "name", None)
|
|
66
|
+
if name is None:
|
|
67
|
+
name = self._get_next_consecutive_integer()
|
|
68
|
+
if name in self._sources:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"The integer {name} that was assigned automatically is already taken. "
|
|
71
|
+
f"This should not have happened."
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
assert isinstance(
|
|
75
|
+
name, str
|
|
76
|
+
), f"Name is expected to be a string, not a {type(name)!r}"
|
|
77
|
+
if name in self._sources:
|
|
78
|
+
raise ValueError(f"Name {name!r} is already taken.")
|
|
79
|
+
self._sources[name] = source
|
|
80
|
+
return name
|
|
81
|
+
|
|
82
|
+
def get_key(self, key: str | int) -> int | str:
|
|
83
|
+
"""Get the key for a given source path or key. If the key is not found, it raises KeyError."""
|
|
84
|
+
if key in self._sources:
|
|
85
|
+
return key
|
|
86
|
+
if isinstance(key, int):
|
|
87
|
+
if key < 0:
|
|
88
|
+
key += len(self._sources)
|
|
89
|
+
if key < 0 or key >= len(self._sources):
|
|
90
|
+
raise IndexError(
|
|
91
|
+
f"Index {key} is out of bounds for sources of length {len(self._sources)}"
|
|
92
|
+
)
|
|
93
|
+
return list(self._sources.keys())[key]
|
|
94
|
+
raise KeyError(f"Source {key!r} not found in sources.")
|
|
95
|
+
|
|
96
|
+
def resolve_keys(
|
|
97
|
+
self, keys: Optional[str | int | Iterable[str | int]] = None
|
|
98
|
+
) -> tuple[str | int, ...]:
|
|
99
|
+
"""Process input arguments."""
|
|
100
|
+
if keys is None:
|
|
101
|
+
return tuple(self._sources.keys())
|
|
102
|
+
keys = [self.get_key(key) for key in make_argument_iterable(keys)]
|
|
103
|
+
assert len(keys) > 0, f"Resolved names have zero-length: {keys!r}"
|
|
104
|
+
return tuple(keys)
|
|
105
|
+
|
|
106
|
+
def get_source(self, key: str | int) -> S:
|
|
107
|
+
"""Retrieve one of the previous inputs by its name."""
|
|
108
|
+
if key in self._sources:
|
|
109
|
+
return self._sources[key]
|
|
110
|
+
elif isinstance(key, int):
|
|
111
|
+
if key < 0:
|
|
112
|
+
key += len(self._sources)
|
|
113
|
+
if key < 0 or key >= len(self._sources):
|
|
114
|
+
raise IndexError(
|
|
115
|
+
f"Index {key} is out of bounds for sources of length {len(self._sources)}"
|
|
116
|
+
)
|
|
117
|
+
return list(self._sources.values())[key]
|
|
118
|
+
raise KeyError(
|
|
119
|
+
f"Name {key!r} is not found in the sources. "
|
|
120
|
+
f"Available names: {tuple(self._sources.keys())}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def get_sources(
|
|
124
|
+
self, names: Optional[str | int | Iterable[str | int]] = None
|
|
125
|
+
) -> tuple[S]:
|
|
126
|
+
if names is None:
|
|
127
|
+
if len(self._sources) == 0:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
"No data has been added to this object. "
|
|
130
|
+
"Use the method ._add_source() first"
|
|
131
|
+
)
|
|
132
|
+
sources = tuple(self._sources.values())
|
|
133
|
+
else:
|
|
134
|
+
names = self.resolve_keys(names)
|
|
135
|
+
sources = tuple(self.get_source(name) for name in names)
|
|
136
|
+
return sources
|
|
137
|
+
|
|
138
|
+
def validate_source(self, source: S):
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
@overload
|
|
142
|
+
def __getitem__(self, names: str | int) -> S: ...
|
|
143
|
+
|
|
144
|
+
@overload
|
|
145
|
+
def __getitem__(self, names: Iterable[str | int]) -> tuple[S]: ...
|
|
146
|
+
|
|
147
|
+
def __getitem__(self, names: str | int | Iterable[str | int]) -> S | tuple[S]:
|
|
148
|
+
if isinstance(names, (str, int)):
|
|
149
|
+
return self.get_source(names)
|
|
150
|
+
return self.get_sources(names)
|
|
151
|
+
|
|
152
|
+
def __iter__(self) -> Iterator[S]:
|
|
153
|
+
yield from self.get_sources()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class DivMaker(NamedSources[np.ndarray[int]]):
|
|
157
|
+
"""This is a convenient object for turning sequences of fractions into commensurate divs.
|
|
158
|
+
It is equivalent to concatenating all sequences, passing them to the function shown below, and splitting them again.
|
|
159
|
+
|
|
160
|
+
def fractions2divs(fracs: Iterable[Fraction]) -> np.ndarray[int]:
|
|
161
|
+
numerators, denominators = np.array([(item.numerator,item.denominator) for item in fracs]).T
|
|
162
|
+
lcm = np.lcm.reduce(denominators) # least common multiple
|
|
163
|
+
return (numerators * lcm / denominators).astype(int)
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
|
|
167
|
+
STAR_WARS = np.array([ # durations of the star wars theme
|
|
168
|
+
(1, 12),
|
|
169
|
+
(1, 12),
|
|
170
|
+
(1, 12),
|
|
171
|
+
(1, 2),
|
|
172
|
+
(1, 2),
|
|
173
|
+
(1, 12),
|
|
174
|
+
(1, 12),
|
|
175
|
+
(1, 12),
|
|
176
|
+
(1, 2),
|
|
177
|
+
(1, 4)
|
|
178
|
+
])
|
|
179
|
+
div_maker = DivMaker(STAR_WARS)
|
|
180
|
+
div_maker[0] # yields [1, 1, 1, 6, 6, 1, 1, 1, 6, 3]
|
|
181
|
+
|
|
182
|
+
POSITIONS = [Fraction(1, 20), Fraction(1, 32)] # fractions that we need our durations to be commensurate with
|
|
183
|
+
div_maker.add_iterable_of_fractions(POSITIONS)
|
|
184
|
+
durations, pos = div_maker # object is iterable (iterates through sequences added without names)
|
|
185
|
+
list(durations) # yields [40, 40, 40, 240, 240, 40, 40, 40, 240, 120]
|
|
186
|
+
|
|
187
|
+
OTHER_VALUES = (Fraction(i, 7) for i in range(7))
|
|
188
|
+
div_maker.add_iterable_or_array(OTHER_VALUES, "other") # add the other values with a name
|
|
189
|
+
div_maker[(1, "other", 0)] # when retrieving we can mix assigned names and indices of nameless sequences
|
|
190
|
+
# OUTPUT:
|
|
191
|
+
# (array([168, 105]),
|
|
192
|
+
# array([ 0, 480, 960, 1440, 1920, 2400, 2880]),
|
|
193
|
+
# array([ 280, 280, 280, 1680, 1680, 280, 280, 280, 1680, 840]))
|
|
194
|
+
|
|
195
|
+
div_maker.lcm # yields 3360, the common denominator for all values (least common multiple)
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
*iterable_or_array: Iterable[Fraction] | np.ndarray[int],
|
|
201
|
+
**named_iterables_or_arrays: Iterable[Fraction] | np.ndarray[int],
|
|
202
|
+
):
|
|
203
|
+
"""Pass one or several 2d-arrays (where one axis has shape 2) or one or several iterables of fractions.
|
|
204
|
+
By passing keyword arguments you can assign names which you can use to retrieve the respective div sequences.
|
|
205
|
+
"""
|
|
206
|
+
self._sources: dict[int | str, np.ndarray[int]] = {}
|
|
207
|
+
for ioa in iterable_or_array:
|
|
208
|
+
_ = self.add_iterable_or_array(ioa)
|
|
209
|
+
for name, ioa in named_iterables_or_arrays.items():
|
|
210
|
+
_ = self.add_iterable_or_array(ioa, name)
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def iterable_of_fractions_to_array(
|
|
214
|
+
iterable_of_fractions: Iterable[Fraction],
|
|
215
|
+
) -> np.ndarray[int]:
|
|
216
|
+
"""Returns a numpy array of shape (2,n) for a given iterable of n :obj:`Fraction` objects."""
|
|
217
|
+
return np.array(
|
|
218
|
+
[(frac.numerator, frac.denominator) for frac in iterable_of_fractions]
|
|
219
|
+
).T
|
|
220
|
+
|
|
221
|
+
def add_iterable_of_fractions(
|
|
222
|
+
self,
|
|
223
|
+
iterable_of_fractions: Iterable[Fraction],
|
|
224
|
+
name: Optional[str | int] = None,
|
|
225
|
+
) -> int | str:
|
|
226
|
+
"""Adds some iterable of :obj:`Fraction` objects that can then be retrieved as divs.
|
|
227
|
+
If you assign a name you can retrieve it under that name, otherwise by the integer corresponding to the
|
|
228
|
+
order in which it was added. Iteration over the object goes only through nameless objects in their adding
|
|
229
|
+
order, meaning that you can assign an integer name that will not be taken into account when iterating
|
|
230
|
+
through the object.
|
|
231
|
+
"""
|
|
232
|
+
arr = self.iterable_of_fractions_to_array(iterable_of_fractions)
|
|
233
|
+
return self.add_iterable_or_array(arr, name=name)
|
|
234
|
+
|
|
235
|
+
def _adapt_source(self, arr: np.ndarray) -> np.ndarray:
|
|
236
|
+
arr = np.asarray(arr)
|
|
237
|
+
assert arr.ndim == 2, f"Expected a 2D numpy array, not {arr.ndim}D"
|
|
238
|
+
assert (
|
|
239
|
+
2 in arr.shape
|
|
240
|
+
), f"One of the 2 dimensions needs to have shape 2. Received shape: {arr.shape}"
|
|
241
|
+
if arr.shape[0] == 2:
|
|
242
|
+
return arr
|
|
243
|
+
return arr.T
|
|
244
|
+
|
|
245
|
+
def add_iterable_or_array(
|
|
246
|
+
self,
|
|
247
|
+
iterable_or_array: Iterable[Fraction] | np.ndarray[int],
|
|
248
|
+
name: Optional[str | int] = None,
|
|
249
|
+
):
|
|
250
|
+
"""Convenience function for calling either .add_iterable_of_fractions() or .add_frac_array() based on the
|
|
251
|
+
input.
|
|
252
|
+
"""
|
|
253
|
+
if isinstance(iterable_or_array, np.ndarray):
|
|
254
|
+
return self._add_source(iterable_or_array, name)
|
|
255
|
+
return self.add_iterable_of_fractions(iterable_or_array, name)
|
|
256
|
+
|
|
257
|
+
def concatenated_frac_arrays(
|
|
258
|
+
self, names: Optional[str | int | Iterable[str | int]] = None
|
|
259
|
+
) -> np.ndarray:
|
|
260
|
+
"""Concatenate the requested arrays in order to compute their LCM. All arrays have shape (2, n) and so does
|
|
261
|
+
their concatenation ("horizontal stacking").
|
|
262
|
+
"""
|
|
263
|
+
arrays = self.get_sources(names)
|
|
264
|
+
if len(arrays) == 1:
|
|
265
|
+
return arrays[0]
|
|
266
|
+
return np.hstack(arrays)
|
|
267
|
+
|
|
268
|
+
def get_divs(self, name: str | int) -> np.ndarray[int]:
|
|
269
|
+
"""Retrieve one of the previous inputs as divs, based on the LCM computed for all inputs together.
|
|
270
|
+
Name can be a number for retrieving nameless inputs based on their input order.
|
|
271
|
+
"""
|
|
272
|
+
if name not in self._sources:
|
|
273
|
+
raise KeyError(name)
|
|
274
|
+
numerators, denominators = self._sources[name]
|
|
275
|
+
lcm = self.least_common_multiple()
|
|
276
|
+
return (numerators * lcm / denominators).astype(int)
|
|
277
|
+
|
|
278
|
+
@cache
|
|
279
|
+
def _least_common_multiple(self, names: tuple[str | int]) -> int:
|
|
280
|
+
_, denominators = self.concatenated_frac_arrays(names)
|
|
281
|
+
return np.lcm.reduce(denominators)
|
|
282
|
+
|
|
283
|
+
def least_common_multiple(
|
|
284
|
+
self, names: Optional[str | int | Iterable[str | int]] = None
|
|
285
|
+
) -> int:
|
|
286
|
+
"""By default, the LCM is computed based on all sequences of fractions that this object holds.
|
|
287
|
+
When you retrieve divs, they are always commensurate between all sequences."""
|
|
288
|
+
names = self.resolve_keys(names)
|
|
289
|
+
return self._least_common_multiple(names)
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def lcm(self):
|
|
293
|
+
"""For convenience."""
|
|
294
|
+
return self.least_common_multiple()
|
|
295
|
+
|
|
296
|
+
@overload
|
|
297
|
+
def __getitem__(self, names: str | int) -> np.ndarray: ...
|
|
298
|
+
|
|
299
|
+
@overload
|
|
300
|
+
def __getitem__(self, names: Iterable[str | int]) -> tuple[np.ndarray]: ...
|
|
301
|
+
|
|
302
|
+
def __getitem__(
|
|
303
|
+
self, names: str | int | Iterable[str | int]
|
|
304
|
+
) -> np.ndarray | tuple[np.ndarray]:
|
|
305
|
+
if isinstance(names, (str, int)):
|
|
306
|
+
return self.get_divs(names)
|
|
307
|
+
names = tuple(names)
|
|
308
|
+
return tuple(self.get_divs(name) for name in names)
|
|
309
|
+
|
|
310
|
+
def __iter__(self):
|
|
311
|
+
existing_consecutive_integers = itertools.takewhile(
|
|
312
|
+
lambda x: x in self._sources, itertools.count()
|
|
313
|
+
)
|
|
314
|
+
for i in existing_consecutive_integers:
|
|
315
|
+
yield self.get_divs(i)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# endregion NamedSources
|
|
319
|
+
# region Enums
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class FancyStrEnum(StrEnum):
|
|
323
|
+
"""This enum is used to define closed vocabularies (e.g. for function arguments) allowing for abbreviation aliases.
|
|
324
|
+
|
|
325
|
+
Features:
|
|
326
|
+
|
|
327
|
+
* It can be instantiated from either value: FancyStrEnum("abbr") == FancyStrEnum.abbreviation.
|
|
328
|
+
* list(FancyStrEnum) returns only non-aliases
|
|
329
|
+
* FancyStrEnum.get_abbreviations() returns a mapping from names to abbreviations
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
|
|
333
|
+
class Vocabulary(FancyStrEnum):
|
|
334
|
+
abbreviation = auto() # assigns the name as value (making it lowercase as per StrEnum's default)
|
|
335
|
+
abbr = abbreviation # alias 1
|
|
336
|
+
abb = abbreviation # alias 2
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
@classmethod
|
|
340
|
+
def _missing_(cls, value):
|
|
341
|
+
"""
|
|
342
|
+
Initialization from values, including aliases.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
value: The value or name string to look up.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
The corresponding TimeUnit enum member.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
ValueError: If the value or name does not match any member or alias.
|
|
352
|
+
"""
|
|
353
|
+
if isinstance(value, str):
|
|
354
|
+
lower_value = value.lower()
|
|
355
|
+
if lower_value in cls.__members__:
|
|
356
|
+
name = cls.__members__[lower_value]
|
|
357
|
+
return cls(name)
|
|
358
|
+
abbrv = cls.get_abbreviations(string=True)
|
|
359
|
+
raise ValueError(
|
|
360
|
+
f"'{value}' is not a valid {cls.__name__}. Available units are: {abbrv}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
@classmethod
|
|
364
|
+
def get_abbreviations(cls, string=False) -> dict[str, str | list[str]]:
|
|
365
|
+
"""Returns a mapping from enum names/values to abbreviated alias values."""
|
|
366
|
+
name2values = defaultdict(list)
|
|
367
|
+
for value, name in cls.__members__.items():
|
|
368
|
+
name2values[name].append(value)
|
|
369
|
+
abbreviations = {}
|
|
370
|
+
for name, values in name2values.items():
|
|
371
|
+
abbreviations[name] = sorted(values, key=lambda x: len(x), reverse=True)[1:]
|
|
372
|
+
if not string:
|
|
373
|
+
return abbreviations
|
|
374
|
+
str_components = []
|
|
375
|
+
for name, values in abbreviations.items():
|
|
376
|
+
if not values:
|
|
377
|
+
str_components.append(name)
|
|
378
|
+
continue
|
|
379
|
+
abbrev_str = ", ".join(values)
|
|
380
|
+
str_components.append(f"{name} ({abbrev_str})")
|
|
381
|
+
return ", ".join(str_components)
|
|
382
|
+
|
|
383
|
+
def __repr__(self):
|
|
384
|
+
return f'"{self.name}"'
|
|
385
|
+
|
|
386
|
+
def __str__(self):
|
|
387
|
+
return self.name
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class Domain(FancyStrEnum):
|
|
391
|
+
musical = auto()
|
|
392
|
+
"""Logical time domain, also called logical time."""
|
|
393
|
+
mu = musical
|
|
394
|
+
|
|
395
|
+
physical = auto()
|
|
396
|
+
"""Physical time domain, also called real time."""
|
|
397
|
+
ph = physical
|
|
398
|
+
|
|
399
|
+
graphical = auto()
|
|
400
|
+
"""Graphical time domain, also called space or visual time."""
|
|
401
|
+
gr = graphical
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class EventCategory(FancyStrEnum):
|
|
405
|
+
"""Enumeration for event categories."""
|
|
406
|
+
|
|
407
|
+
segments = auto()
|
|
408
|
+
segment = segments
|
|
409
|
+
seg = segments
|
|
410
|
+
events = auto()
|
|
411
|
+
event = events
|
|
412
|
+
evt = events
|
|
413
|
+
instant_events = auto()
|
|
414
|
+
instant_event = instant_events
|
|
415
|
+
inst_evt = instant_events
|
|
416
|
+
inst = instant_events
|
|
417
|
+
interval_events = auto()
|
|
418
|
+
interval_event = interval_events
|
|
419
|
+
intv_evt = interval_events
|
|
420
|
+
intv = interval_events
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class InstantType(FancyStrEnum):
|
|
424
|
+
"""Enumeration for types of instants."""
|
|
425
|
+
|
|
426
|
+
instants = auto()
|
|
427
|
+
instant = instants
|
|
428
|
+
inst = instant
|
|
429
|
+
starts = auto()
|
|
430
|
+
start = starts
|
|
431
|
+
ends = auto()
|
|
432
|
+
end = ends
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class IndexType(FancyStrEnum):
|
|
436
|
+
"""Enumeration for types of indices."""
|
|
437
|
+
|
|
438
|
+
intervals = auto()
|
|
439
|
+
interval = intervals
|
|
440
|
+
intv = intervals
|
|
441
|
+
instants = auto()
|
|
442
|
+
instant = instants
|
|
443
|
+
inst = instants
|
|
444
|
+
starts = auto()
|
|
445
|
+
start = starts
|
|
446
|
+
ends = auto()
|
|
447
|
+
end = ends
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class InterpolationType(FancyStrEnum):
|
|
451
|
+
linear = auto()
|
|
452
|
+
nearest = auto()
|
|
453
|
+
nearest_up = "nearest-up"
|
|
454
|
+
zero = auto()
|
|
455
|
+
slinear = auto()
|
|
456
|
+
quadratic = auto()
|
|
457
|
+
cubic = auto()
|
|
458
|
+
previous = auto()
|
|
459
|
+
next = auto()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class Missing(FancyStrEnum):
|
|
463
|
+
DATA_UNDEFINED = auto()
|
|
464
|
+
FIELD_MISSING_FROM_EVENT_DATA = auto()
|
|
465
|
+
FIELD_NAME_UNDEFINED = auto()
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class NumberType(Enum):
|
|
469
|
+
"""Members can be instantiated both via NumberType("name") and NumberType(value).
|
|
470
|
+
|
|
471
|
+
Example:
|
|
472
|
+
NumberType(int) is NumberType("int")
|
|
473
|
+
# True
|
|
474
|
+
NumberType(int).value(1.4)
|
|
475
|
+
# 1
|
|
476
|
+
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
int = int
|
|
480
|
+
float = float
|
|
481
|
+
fraction = Fraction
|
|
482
|
+
|
|
483
|
+
@classmethod
|
|
484
|
+
def _missing_(cls, value):
|
|
485
|
+
if isinstance(value, str):
|
|
486
|
+
for member in cls:
|
|
487
|
+
if member.name == value:
|
|
488
|
+
return member
|
|
489
|
+
try:
|
|
490
|
+
converted_numpy = convert_numpy_type_to_python_type(value)
|
|
491
|
+
return cls(converted_numpy)
|
|
492
|
+
except Exception:
|
|
493
|
+
pass
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
@classmethod
|
|
497
|
+
def from_number(cls, number: Number):
|
|
498
|
+
return cls(type(number))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class PartMap(FancyStrEnum):
|
|
502
|
+
time_signature_map = auto()
|
|
503
|
+
ts_map = time_signature_map
|
|
504
|
+
tsm = time_signature_map
|
|
505
|
+
|
|
506
|
+
key_signature_map = auto()
|
|
507
|
+
ks_map = key_signature_map
|
|
508
|
+
ksm = key_signature_map
|
|
509
|
+
|
|
510
|
+
clef_map = auto()
|
|
511
|
+
cm = clef_map
|
|
512
|
+
|
|
513
|
+
measure_map = auto()
|
|
514
|
+
mc = measure_map
|
|
515
|
+
|
|
516
|
+
measure_number_map = auto()
|
|
517
|
+
mn = measure_number_map
|
|
518
|
+
|
|
519
|
+
metrical_position_map = auto()
|
|
520
|
+
mpm = metrical_position_map
|
|
521
|
+
|
|
522
|
+
beat_map = auto()
|
|
523
|
+
bm = beat_map
|
|
524
|
+
|
|
525
|
+
inv_beat_map = auto()
|
|
526
|
+
ibm = inv_beat_map
|
|
527
|
+
|
|
528
|
+
quarter_map = auto()
|
|
529
|
+
qm = quarter_map
|
|
530
|
+
|
|
531
|
+
inv_quarter_map = auto()
|
|
532
|
+
iqm = inv_quarter_map
|
|
533
|
+
|
|
534
|
+
quarter_duration_map = auto()
|
|
535
|
+
qdm = quarter_duration_map
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
class Quantization(FancyStrEnum):
|
|
539
|
+
continuous = auto()
|
|
540
|
+
"""Accomodating arbitrarily precise coordinates."""
|
|
541
|
+
|
|
542
|
+
discrete = auto()
|
|
543
|
+
"""Accomodating only integer coordinates."""
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class TimeUnit(FancyStrEnum):
|
|
547
|
+
""""""
|
|
548
|
+
|
|
549
|
+
# generic
|
|
550
|
+
number = auto()
|
|
551
|
+
|
|
552
|
+
# musical
|
|
553
|
+
beats = auto()
|
|
554
|
+
"""beats"""
|
|
555
|
+
b = beats # b is an alias for beats
|
|
556
|
+
"""beats"""
|
|
557
|
+
|
|
558
|
+
measures = auto()
|
|
559
|
+
"""measures"""
|
|
560
|
+
m = measures # m is an alias for measures
|
|
561
|
+
"""measures"""
|
|
562
|
+
|
|
563
|
+
quarters = auto()
|
|
564
|
+
"""quarter notes"""
|
|
565
|
+
q = quarters # q is an alias for quarters
|
|
566
|
+
"""quarter notes"""
|
|
567
|
+
|
|
568
|
+
ticks = auto()
|
|
569
|
+
"""ticks (MIDI's time unit)"""
|
|
570
|
+
pulses = ticks
|
|
571
|
+
"""ticks (MIDI's time unit)"""
|
|
572
|
+
|
|
573
|
+
# physical
|
|
574
|
+
milliseconds = auto()
|
|
575
|
+
"""milliseconds"""
|
|
576
|
+
ms = milliseconds # ms is an alias for milliseconds
|
|
577
|
+
"""milliseconds"""
|
|
578
|
+
|
|
579
|
+
seconds = auto()
|
|
580
|
+
"""seconds"""
|
|
581
|
+
s = seconds # s is an alias for seconds
|
|
582
|
+
"""seconds"""
|
|
583
|
+
|
|
584
|
+
minutes = auto()
|
|
585
|
+
"""minutes"""
|
|
586
|
+
|
|
587
|
+
samples = auto()
|
|
588
|
+
"""samples"""
|
|
589
|
+
|
|
590
|
+
# graphical
|
|
591
|
+
pixels = auto()
|
|
592
|
+
"""pixels"""
|
|
593
|
+
px = pixels # px is an alias for pixels
|
|
594
|
+
"""pixels"""
|
|
595
|
+
|
|
596
|
+
meters = auto()
|
|
597
|
+
"""meters"""
|
|
598
|
+
centimeters = auto()
|
|
599
|
+
"""centimeters"""
|
|
600
|
+
cm = centimeters # cm is an alias for centimeters
|
|
601
|
+
"""centimeters"""
|
|
602
|
+
millimeters = auto()
|
|
603
|
+
"""millimeters"""
|
|
604
|
+
mm = millimeters # mm is an alias for millimeters
|
|
605
|
+
"""millimeters"""
|
|
606
|
+
|
|
607
|
+
inches = auto()
|
|
608
|
+
"""inches"""
|
|
609
|
+
|
|
610
|
+
points = auto()
|
|
611
|
+
"""points"""
|
|
612
|
+
pt = points # pt is an alias for points
|
|
613
|
+
"""points"""
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
class TraversalOrder(FancyStrEnum):
|
|
617
|
+
"""Enumeration for traversal orders."""
|
|
618
|
+
|
|
619
|
+
breadth_first = auto()
|
|
620
|
+
depth_first = auto()
|
|
621
|
+
sorted = auto()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# endregion Enums
|
|
625
|
+
# region helper functions
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def convert_numpy_type_to_python_type(np_type) -> Type:
|
|
629
|
+
"""Turns a numpy dtype into a native Python type"""
|
|
630
|
+
return type(np.zeros(1, np_type).tolist()[0])
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def get_time_units(
|
|
634
|
+
domain=Domain | Iterable[Domain], quantization=Quantization | Iterable[Quantization]
|
|
635
|
+
) -> tuple[TimeUnit]:
|
|
636
|
+
result = []
|
|
637
|
+
if isinstance(domain, str):
|
|
638
|
+
domain = Domain(domain)
|
|
639
|
+
domain_dicts = [LINEAR_TIME_UNITS[domain]]
|
|
640
|
+
else:
|
|
641
|
+
domains = [Domain(d) for d in domain]
|
|
642
|
+
domain_dicts = [LINEAR_TIME_UNITS[d] for d in domains]
|
|
643
|
+
if isinstance(quantization, str):
|
|
644
|
+
quantization = Quantization(quantization)
|
|
645
|
+
for dd in domain_dicts:
|
|
646
|
+
result.extend(dd[quantization])
|
|
647
|
+
else:
|
|
648
|
+
quantizations = [Quantization(q) for q in quantization]
|
|
649
|
+
for dd in domain_dicts:
|
|
650
|
+
for q in quantizations:
|
|
651
|
+
result.extend(dd[q])
|
|
652
|
+
return tuple(result)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def calculate_file_checksum(
|
|
656
|
+
filepath: Path | str,
|
|
657
|
+
hash_algorithm: Literal["sha256", "md5", "sha1", "sha512"] = "sha256",
|
|
658
|
+
chunk_size=8192,
|
|
659
|
+
):
|
|
660
|
+
"""
|
|
661
|
+
Calculates a system-agnostic checksum of a file.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
filepath: The path to the file.
|
|
665
|
+
hash_algorithm: The hashing algorithm to use (e.g., 'md5', 'sha1', 'sha256', 'sha512').
|
|
666
|
+
Defaults to 'sha256'.
|
|
667
|
+
chunk_size: The size of chunks (in bytes) to read the file.
|
|
668
|
+
Larger chunks can be faster but use more memory.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
str: The hexadecimal digest of the file's checksum, or None if the file is not found.
|
|
672
|
+
"""
|
|
673
|
+
if not osp.isfile(filepath):
|
|
674
|
+
logger.error(f"File not found at {filepath}")
|
|
675
|
+
return None
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
# Get the hash constructor from hashlib
|
|
679
|
+
hasher = hashlib.new(hash_algorithm)
|
|
680
|
+
except ValueError:
|
|
681
|
+
logger.error(
|
|
682
|
+
f"Error: Unsupported hash algorithm '{hash_algorithm}'. "
|
|
683
|
+
f"Available algorithms: {hashlib.algorithms_available}"
|
|
684
|
+
)
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
with open(filepath, "rb") as f:
|
|
689
|
+
while True:
|
|
690
|
+
chunk = f.read(chunk_size)
|
|
691
|
+
if not chunk:
|
|
692
|
+
break # End of file
|
|
693
|
+
hasher.update(chunk)
|
|
694
|
+
return hasher.hexdigest()
|
|
695
|
+
except IOError as e:
|
|
696
|
+
logger.error(f"Error reading file {filepath}: {e}")
|
|
697
|
+
return None
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@overload
|
|
701
|
+
def make_argument_iterable(arg: Iterable) -> Iterable: ...
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
@overload
|
|
705
|
+
def make_argument_iterable(arg: Any) -> tuple: ...
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def make_argument_iterable(
|
|
709
|
+
arg, make_iterable_singular: bool = False
|
|
710
|
+
) -> tuple | Iterable:
|
|
711
|
+
"""make_iterable_singular can be set to True for cases where the result is expected to be an
|
|
712
|
+
iterable of length one, e.g. "one tuple", in which case any iterable of length > 1 will be wrapped.
|
|
713
|
+
"""
|
|
714
|
+
if arg is None:
|
|
715
|
+
return tuple()
|
|
716
|
+
if isinstance(arg, str):
|
|
717
|
+
return (arg,)
|
|
718
|
+
if not isinstance(arg, Iterable):
|
|
719
|
+
return (arg,)
|
|
720
|
+
try:
|
|
721
|
+
if make_iterable_singular and len(arg) > 1:
|
|
722
|
+
return (arg,)
|
|
723
|
+
except TypeError:
|
|
724
|
+
# arg probably a generator
|
|
725
|
+
pass
|
|
726
|
+
return arg
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def new_interval_index(
|
|
730
|
+
iix: pd.IntervalIndex,
|
|
731
|
+
mask: pd.Series,
|
|
732
|
+
new_left: Optional[int | float] = None,
|
|
733
|
+
new_right: Optional[int | float] = None,
|
|
734
|
+
new_closed=None,
|
|
735
|
+
) -> pd.IntervalIndex:
|
|
736
|
+
"""New values that are of type Fraction will be converted to float."""
|
|
737
|
+
closed = new_closed if new_closed else iix.closed
|
|
738
|
+
if new_left is None and new_right is None:
|
|
739
|
+
return pd.IntervalIndex(iix[mask], closed=closed)
|
|
740
|
+
iv_list = iix.to_numpy().tolist()
|
|
741
|
+
(update_positions,) = np.where(mask)
|
|
742
|
+
for i in update_positions:
|
|
743
|
+
interval = iv_list[i]
|
|
744
|
+
if new_left is None:
|
|
745
|
+
left = interval.left
|
|
746
|
+
else:
|
|
747
|
+
left = to_float_if_fraction(new_left)
|
|
748
|
+
if new_right is None:
|
|
749
|
+
right = interval.right
|
|
750
|
+
else:
|
|
751
|
+
right = to_float_if_fraction(new_right)
|
|
752
|
+
iv_list[i] = pd.Interval(left, right, closed)
|
|
753
|
+
return pd.IntervalIndex(iv_list, name=iix.name, closed=closed)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
@overload
|
|
757
|
+
def to_float_if_fraction(shift_by: Number) -> Number: ...
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@overload
|
|
761
|
+
def to_float_if_fraction(shift_by: Fraction) -> float: ...
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def to_float_if_fraction(shift_by: Number) -> Number:
|
|
765
|
+
if isinstance(shift_by, Fraction):
|
|
766
|
+
shift_by = float(shift_by)
|
|
767
|
+
return shift_by
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def shift_interval_index(
|
|
771
|
+
iix: pd.IntervalIndex, shift_by=int | float
|
|
772
|
+
) -> pd.IntervalIndex:
|
|
773
|
+
shift_by = to_float_if_fraction(shift_by)
|
|
774
|
+
new_left = iix.left + shift_by
|
|
775
|
+
new_right = iix.right + shift_by
|
|
776
|
+
return pd.IntervalIndex.from_arrays(
|
|
777
|
+
new_left, new_right, name=iix.name, closed=iix.closed
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def longer_intervals_first(
|
|
782
|
+
iix: pd.IntervalIndex,
|
|
783
|
+
) -> pd.Series:
|
|
784
|
+
"""Helper to be used in .sort_index(key=longer_intervals_first)."""
|
|
785
|
+
return pd.Series(list(zip(iix.left.values, -iix.length.values)))
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def sort_interval_index_longer_first(
|
|
789
|
+
iix: pd.IntervalIndex,
|
|
790
|
+
) -> pd.IntervalIndex:
|
|
791
|
+
return iix[longer_intervals_first(iix).argsort()]
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def sort_instants(df):
|
|
795
|
+
"""
|
|
796
|
+
Sorts instants according to index and 'instant_type' column such that end instants
|
|
797
|
+
precede coinciding start instants.
|
|
798
|
+
"""
|
|
799
|
+
sort_order = pd.Index(
|
|
800
|
+
df.instant_type.items()
|
|
801
|
+
).argsort() # sorting by (index, instant_type) tuples
|
|
802
|
+
return df.iloc[sort_order]
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def make_unbounded_interval_index(
|
|
806
|
+
iix: pd.IntervalIndex,
|
|
807
|
+
left_unbounded: bool = False,
|
|
808
|
+
right_unbounded: bool = True,
|
|
809
|
+
):
|
|
810
|
+
"""Takes a monotonically increasing IntervalIndex and extends the first and the last interval
|
|
811
|
+
to -inf and inf so that it returns the first or last value for out-of-bounds queries. By default,
|
|
812
|
+
only the right interval is replaced with one right-bounded by inf. Set left_unbounded=True to also
|
|
813
|
+
replace the first interval with one left-bounded by -inf.
|
|
814
|
+
"""
|
|
815
|
+
assert iix.is_non_overlapping_monotonic
|
|
816
|
+
intervals = iix.to_list()
|
|
817
|
+
first, last = intervals[0], intervals[-1]
|
|
818
|
+
assert first.left < last.right
|
|
819
|
+
infinite = float("inf")
|
|
820
|
+
if left_unbounded:
|
|
821
|
+
intervals[0] = pd.Interval(-infinite, first.right, closed=first.closed)
|
|
822
|
+
if right_unbounded:
|
|
823
|
+
intervals[-1] = pd.Interval(last.left, infinite, closed=last.closed)
|
|
824
|
+
return pd.IntervalIndex(intervals, name=iix.name, closed=iix.closed)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def replace_interval_index_with_unbounded_one(
|
|
828
|
+
interval_map: pd.DataFrame | pd.Series,
|
|
829
|
+
left_unbounded: bool = False,
|
|
830
|
+
right_unbounded: bool = True,
|
|
831
|
+
):
|
|
832
|
+
if not (left_unbounded or right_unbounded):
|
|
833
|
+
return interval_map
|
|
834
|
+
unbounded_index = make_unbounded_interval_index(
|
|
835
|
+
interval_map.index,
|
|
836
|
+
left_unbounded=left_unbounded,
|
|
837
|
+
right_unbounded=right_unbounded,
|
|
838
|
+
)
|
|
839
|
+
return interval_map.set_axis(unbounded_index)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def validate_interval_args(
|
|
843
|
+
start: Coordinate,
|
|
844
|
+
end: Optional[Coordinate | Number] = None,
|
|
845
|
+
length: Optional[Coordinate | Number] = None,
|
|
846
|
+
):
|
|
847
|
+
assert not (
|
|
848
|
+
end is None and length is None
|
|
849
|
+
), "At least one of 'end' or 'length' must be defined."
|
|
850
|
+
if end is not None and length is not None:
|
|
851
|
+
assert (
|
|
852
|
+
end == start + length
|
|
853
|
+
), f"The given {end=} does not correspond to {start+length=}"
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def python_type_to_numpy_dtype(py_type):
|
|
857
|
+
if isinstance(py_type, np.dtype):
|
|
858
|
+
return py_type
|
|
859
|
+
if py_type is int:
|
|
860
|
+
return np.dtype(int)
|
|
861
|
+
elif py_type is float:
|
|
862
|
+
return np.dtype(float)
|
|
863
|
+
elif py_type is bool:
|
|
864
|
+
return np.dtype(bool)
|
|
865
|
+
elif py_type is str:
|
|
866
|
+
return np.dtype(object)
|
|
867
|
+
elif py_type is list:
|
|
868
|
+
return np.dtype(object)
|
|
869
|
+
elif py_type is tuple:
|
|
870
|
+
return np.dtype(object)
|
|
871
|
+
elif py_type is dict:
|
|
872
|
+
return np.dtype(object)
|
|
873
|
+
elif py_type is complex:
|
|
874
|
+
return np.dtype(complex)
|
|
875
|
+
elif py_type is bytes:
|
|
876
|
+
return np.dtype(bytes)
|
|
877
|
+
elif py_type is type(None):
|
|
878
|
+
return np.dtype(object)
|
|
879
|
+
else:
|
|
880
|
+
return np.dtype(object)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def compute_active_intervals(df_or_s: pd.DataFrame | pd.Series) -> pd.Series:
|
|
884
|
+
"""Based on the column 'instant_type' consisting of the values 'starts', 'ends', and 'instants',
|
|
885
|
+
return a column of integers that represents the number of active intervals at any given point.
|
|
886
|
+
The function assumes that the events are chronologically ordered such that 'ends' precede any
|
|
887
|
+
co-occurring 'starts'. This is what guarantees that 0 values mark those moments where no
|
|
888
|
+
intervals are active. If no 0-values are returned, chances are that there are intervals covering
|
|
889
|
+
the entire time range, such as event-category 'segments'.
|
|
890
|
+
"""
|
|
891
|
+
if isinstance(df_or_s, pd.Series):
|
|
892
|
+
ser = df_or_s
|
|
893
|
+
elif "instant_type" in df_or_s.columns:
|
|
894
|
+
ser = df_or_s.instant_type
|
|
895
|
+
else:
|
|
896
|
+
assert isinstance(
|
|
897
|
+
df_or_s.index, pd.IntervalIndex
|
|
898
|
+
), "DataFrame needs to have an 'instant_type' column or a pd.IntervalIndex"
|
|
899
|
+
raise NotImplementedError("pd.IntervalIndex needs a different function.")
|
|
900
|
+
inst_types = set(INSTANT_TYPE_ACTIVITY_CHANGE.keys())
|
|
901
|
+
assert all(
|
|
902
|
+
val in inst_types for val in ser.unique()
|
|
903
|
+
), f"Series has values that are not instant types: {ser.unique()}"
|
|
904
|
+
return ser.map(INSTANT_TYPE_ACTIVITY_CHANGE).cumsum()
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def treat_variadic_argument(*args) -> list:
|
|
908
|
+
"""Catch cases where a single iterable was given accidentally without unpacking."""
|
|
909
|
+
if len(args) == 1:
|
|
910
|
+
elem = args[0]
|
|
911
|
+
if isinstance(elem, Iterable) and not isinstance(elem, (str, bytes)):
|
|
912
|
+
logger.debug(
|
|
913
|
+
f"Variadic argument with a single {type(elem).__name__} has been unpacked."
|
|
914
|
+
)
|
|
915
|
+
return elem
|
|
916
|
+
return args
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def get_boolean_mask_for_intervals(
|
|
920
|
+
values: np.ndarray, iix: pd.IntervalIndex
|
|
921
|
+
) -> np.ndarray:
|
|
922
|
+
"""
|
|
923
|
+
Returns a boolean mask for a NumPy array where True indicates that the value of the
|
|
924
|
+
array at that position falls within any interval in IntervalIndex J.
|
|
925
|
+
"""
|
|
926
|
+
mask = np.full(values.shape, False, dtype=bool)
|
|
927
|
+
for interval in iix:
|
|
928
|
+
mask = mask | ((values >= interval.left) & (values <= interval.right))
|
|
929
|
+
return mask
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
# endregion helper functions
|
|
933
|
+
# region globals
|
|
934
|
+
|
|
935
|
+
# this controls the outputs of get_time_units() which, in return, controls the
|
|
936
|
+
# _allowed_units class attributes of the various Timeline classes.
|
|
937
|
+
LINEAR_TIME_UNITS = dict(
|
|
938
|
+
musical=dict(
|
|
939
|
+
continuous=[TimeUnit.quarters],
|
|
940
|
+
discrete=[TimeUnit.ticks],
|
|
941
|
+
),
|
|
942
|
+
physical=dict(
|
|
943
|
+
continuous=[TimeUnit.milliseconds, TimeUnit.seconds],
|
|
944
|
+
discrete=[TimeUnit.samples],
|
|
945
|
+
),
|
|
946
|
+
graphical=dict(
|
|
947
|
+
continuous=[
|
|
948
|
+
TimeUnit.meters,
|
|
949
|
+
TimeUnit.centimeters,
|
|
950
|
+
TimeUnit.millimeters,
|
|
951
|
+
TimeUnit.inches,
|
|
952
|
+
TimeUnit.points,
|
|
953
|
+
],
|
|
954
|
+
discrete=[TimeUnit.pixels],
|
|
955
|
+
),
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
INSTANT_TYPE_ACTIVITY_CHANGE = dict(
|
|
959
|
+
instants=0,
|
|
960
|
+
starts=1,
|
|
961
|
+
ends=-1,
|
|
962
|
+
)
|
|
963
|
+
# endregion globals
|