hyperscore 0.1.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.
- hyperscore/__init__.py +35 -0
- hyperscore/core/__init__.py +42 -0
- hyperscore/core/score.py +369 -0
- hyperscore/core/time.py +100 -0
- hyperscore/core/time_pipeline.py +118 -0
- hyperscore/core/time_transforms.py +109 -0
- hyperscore/io/__init__.py +29 -0
- hyperscore/io/midi.py +355 -0
- hyperscore/py.typed +0 -0
- hyperscore/rhythm/__init__.py +32 -0
- hyperscore/rhythm/rhythm_tree.py +384 -0
- hyperscore/theory/__init__.py +41 -0
- hyperscore/theory/pcset.py +145 -0
- hyperscore/theory/scales.py +296 -0
- hyperscore-0.1.0.dist-info/METADATA +128 -0
- hyperscore-0.1.0.dist-info/RECORD +17 -0
- hyperscore-0.1.0.dist-info/WHEEL +4 -0
hyperscore/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hyperscore: a structural music composition framework.
|
|
3
|
+
|
|
4
|
+
hyperscore provides a minimal, composable foundation for
|
|
5
|
+
algorithmic and structural music composition.
|
|
6
|
+
|
|
7
|
+
Key ideas
|
|
8
|
+
---------
|
|
9
|
+
- Music is modeled as explicit structure, not notation.
|
|
10
|
+
- Time is represented uniformly using immutable TimeSpan objects.
|
|
11
|
+
- Pitch is handled as pitch-class structure, independent of register.
|
|
12
|
+
- External formats (e.g. MIDI) are treated as lossy boundaries.
|
|
13
|
+
|
|
14
|
+
The library is organized into clear layers:
|
|
15
|
+
- core: time and event primitives
|
|
16
|
+
- rhythm: structural rhythm descriptions
|
|
17
|
+
- theory: pitch-class and scale structures
|
|
18
|
+
- io: adapters to external systems
|
|
19
|
+
|
|
20
|
+
hyperscore is designed for experimentation, analysis,
|
|
21
|
+
and integration with other algorithmic systems,
|
|
22
|
+
rather than for direct score engraving or DAW replacement.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from hyperscore.core import Score, ZippedNotes
|
|
26
|
+
from hyperscore.rhythm import parse_rhythm
|
|
27
|
+
from hyperscore.theory import CHORDS, SCALES
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CHORDS",
|
|
31
|
+
"SCALES",
|
|
32
|
+
"Score",
|
|
33
|
+
"ZippedNotes",
|
|
34
|
+
"parse_rhythm",
|
|
35
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core time and event primitives for hyperscore.
|
|
3
|
+
|
|
4
|
+
This module defines the foundational abstractions used throughout
|
|
5
|
+
hyperscore:
|
|
6
|
+
|
|
7
|
+
- TimeSpan: immutable representation of linear time intervals
|
|
8
|
+
- NoteEvent: minimal time-bounded musical event
|
|
9
|
+
- Score: container and coordinator for events on a time axis
|
|
10
|
+
- TimeSpanPipeline: composable transformations over TimeSpans
|
|
11
|
+
- ZippedNotes: convenience API for simple sequential note generation
|
|
12
|
+
|
|
13
|
+
Design principles
|
|
14
|
+
-----------------
|
|
15
|
+
- Time is represented explicitly and uniformly via TimeSpan.
|
|
16
|
+
- Core abstractions are unit-agnostic (milliseconds, ticks, etc.).
|
|
17
|
+
- Musical interpretation (harmony, rhythm, MIDI) is handled in
|
|
18
|
+
higher-level modules.
|
|
19
|
+
- All core objects favor immutability and composability.
|
|
20
|
+
|
|
21
|
+
This module is intentionally minimal and free of musical semantics.
|
|
22
|
+
It serves as the stable foundation upon which rhythm, theory,
|
|
23
|
+
and I/O layers are built.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .score import NoteEvent, Score, ZippedNotes
|
|
27
|
+
from .time import TimeSpan, bpm_to_ms
|
|
28
|
+
from .time_pipeline import TimeSpanPipeline
|
|
29
|
+
from .time_transforms import gate, probability, shift, stretch
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"NoteEvent",
|
|
33
|
+
"Score",
|
|
34
|
+
"TimeSpan",
|
|
35
|
+
"TimeSpanPipeline",
|
|
36
|
+
"ZippedNotes",
|
|
37
|
+
"bpm_to_ms",
|
|
38
|
+
"gate",
|
|
39
|
+
"probability",
|
|
40
|
+
"shift",
|
|
41
|
+
"stretch",
|
|
42
|
+
]
|
hyperscore/core/score.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
4
|
+
from dataclasses import dataclass, field, fields
|
|
5
|
+
from typing import Generic, Protocol, TypeVar
|
|
6
|
+
|
|
7
|
+
from .time import TimeSpan
|
|
8
|
+
|
|
9
|
+
# ============================================================
|
|
10
|
+
# Event model (default)
|
|
11
|
+
# ============================================================
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HasTimeSpan(Protocol):
|
|
15
|
+
"""
|
|
16
|
+
Protocol for time-aware events.
|
|
17
|
+
|
|
18
|
+
Any event type stored in a Score must expose
|
|
19
|
+
a ``span`` attribute of type TimeSpan.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
span: TimeSpan
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class NoteEvent:
|
|
27
|
+
"""
|
|
28
|
+
Default note event implementation.
|
|
29
|
+
|
|
30
|
+
This is a minimal, time-bounded musical event.
|
|
31
|
+
Interpretation (e.g. MIDI, synthesis) is handled
|
|
32
|
+
by downstream systems.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
pitch: int
|
|
36
|
+
velocity: int
|
|
37
|
+
span: TimeSpan
|
|
38
|
+
channel: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
EventT = TypeVar("EventT", bound=HasTimeSpan)
|
|
42
|
+
|
|
43
|
+
# ============================================================
|
|
44
|
+
# Score context
|
|
45
|
+
# ============================================================
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ScoreContext:
|
|
50
|
+
"""
|
|
51
|
+
Immutable score evaluation context.
|
|
52
|
+
|
|
53
|
+
The context tracks the current temporal cursor
|
|
54
|
+
during event generation.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
cursor: int
|
|
58
|
+
|
|
59
|
+
def advance(self, delta: int) -> ScoreContext:
|
|
60
|
+
"""
|
|
61
|
+
Return a new context advanced by the given duration.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
delta : int
|
|
66
|
+
Time increment in milliseconds.
|
|
67
|
+
"""
|
|
68
|
+
return ScoreContext(cursor=self.cursor + delta)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ============================================================
|
|
72
|
+
# ScoreInput protocol (event generator)
|
|
73
|
+
# ============================================================
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ScoreInput(Protocol[EventT]):
|
|
77
|
+
"""
|
|
78
|
+
Protocol for event generators consumable by Score.
|
|
79
|
+
|
|
80
|
+
Implementations generate events based on the given
|
|
81
|
+
ScoreContext and return the updated context.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def iter_events(
|
|
85
|
+
self,
|
|
86
|
+
ctx: ScoreContext,
|
|
87
|
+
) -> tuple[list[EventT], ScoreContext]: ...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ============================================================
|
|
91
|
+
# EventFactory protocol
|
|
92
|
+
# ============================================================
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
EventFactory = Callable[..., EventT]
|
|
96
|
+
|
|
97
|
+
# ============================================================
|
|
98
|
+
# ZippedNotes (generic, factory-based)
|
|
99
|
+
# ============================================================
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class ZippedNotes(Generic[EventT]):
|
|
104
|
+
"""
|
|
105
|
+
Convenience builder for sequential note generation
|
|
106
|
+
using zipped parameter lists.
|
|
107
|
+
|
|
108
|
+
Parameters are cycled independently and combined
|
|
109
|
+
position-wise.
|
|
110
|
+
|
|
111
|
+
Notes
|
|
112
|
+
-----
|
|
113
|
+
This class is intended for simple use cases.
|
|
114
|
+
For advanced temporal control, prefer:
|
|
115
|
+
|
|
116
|
+
- rhythm_tree
|
|
117
|
+
- TimeSpan pipelines
|
|
118
|
+
- Score.add_timespans()
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
# ---- core zipped parameters ----
|
|
122
|
+
pitch: Sequence[int] = field(default_factory=lambda: [60])
|
|
123
|
+
velocity: Sequence[int] = field(default_factory=lambda: [100])
|
|
124
|
+
duration: Sequence[int] = field(default_factory=lambda: [1000])
|
|
125
|
+
channel: Sequence[int] = field(default_factory=lambda: [0])
|
|
126
|
+
|
|
127
|
+
# ---- extensibility ----
|
|
128
|
+
event_factory: EventFactory[EventT] | None = None
|
|
129
|
+
|
|
130
|
+
# --------------------------------
|
|
131
|
+
|
|
132
|
+
def _max_len(self) -> int:
|
|
133
|
+
"""
|
|
134
|
+
Return the maximum length among zipped parameters.
|
|
135
|
+
"""
|
|
136
|
+
return max(len(getattr(self, f.name)) for f in fields(self) if f.name != "event_factory")
|
|
137
|
+
|
|
138
|
+
def iter_events(self, ctx: ScoreContext) -> tuple[list[EventT], ScoreContext]:
|
|
139
|
+
"""
|
|
140
|
+
Generate events sequentially starting from the given context.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
ctx : ScoreContext
|
|
145
|
+
Initial evaluation context.
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
list of EventT
|
|
150
|
+
Generated events.
|
|
151
|
+
ScoreContext
|
|
152
|
+
Updated context after all events.
|
|
153
|
+
"""
|
|
154
|
+
if self.event_factory is None:
|
|
155
|
+
raise ValueError("event_factory must be provided")
|
|
156
|
+
|
|
157
|
+
events: list[EventT] = []
|
|
158
|
+
cur = ctx
|
|
159
|
+
|
|
160
|
+
for i in range(self._max_len()):
|
|
161
|
+
d = self.duration[i % len(self.duration)]
|
|
162
|
+
span = TimeSpan(start=cur.cursor, duration=d)
|
|
163
|
+
|
|
164
|
+
kwargs = {
|
|
165
|
+
"pitch": self.pitch[i % len(self.pitch)],
|
|
166
|
+
"velocity": self.velocity[i % len(self.velocity)],
|
|
167
|
+
"span": span,
|
|
168
|
+
"channel": self.channel[i % len(self.channel)],
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
ev = self.event_factory(**kwargs)
|
|
172
|
+
events.append(ev)
|
|
173
|
+
|
|
174
|
+
cur = cur.advance(d)
|
|
175
|
+
|
|
176
|
+
return events, cur
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ============================================================
|
|
180
|
+
# Score
|
|
181
|
+
# ============================================================
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Score(Generic[EventT], Iterable[EventT]):
|
|
185
|
+
"""
|
|
186
|
+
Container and coordinator for time-bounded events.
|
|
187
|
+
|
|
188
|
+
Score does not enforce musical semantics.
|
|
189
|
+
It manages event ordering, temporal queries,
|
|
190
|
+
and integration of event sources.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self):
|
|
194
|
+
self._context: ScoreContext = ScoreContext(cursor=0)
|
|
195
|
+
self._events: list[EventT] = []
|
|
196
|
+
self._sorted_by_start: list[EventT] = []
|
|
197
|
+
self._dirty: bool = False
|
|
198
|
+
|
|
199
|
+
def __iter__(self) -> Iterator[EventT]:
|
|
200
|
+
"""
|
|
201
|
+
Iterate over all events in ascending start-time order.
|
|
202
|
+
"""
|
|
203
|
+
self._ensure_sorted()
|
|
204
|
+
return iter(self._sorted_by_start)
|
|
205
|
+
|
|
206
|
+
# ---------------- cursor ----------------
|
|
207
|
+
|
|
208
|
+
def get_cursor(self) -> int:
|
|
209
|
+
"""
|
|
210
|
+
Return the current score cursor position.
|
|
211
|
+
"""
|
|
212
|
+
return self._context.cursor
|
|
213
|
+
|
|
214
|
+
def set_cursor(self, cursor: int) -> None:
|
|
215
|
+
"""
|
|
216
|
+
Set the score cursor to an explicit position.
|
|
217
|
+
|
|
218
|
+
This affects subsequent event generation.
|
|
219
|
+
"""
|
|
220
|
+
self._context = ScoreContext(cursor=cursor)
|
|
221
|
+
|
|
222
|
+
# ---------------- add ----------------
|
|
223
|
+
|
|
224
|
+
def add(
|
|
225
|
+
self,
|
|
226
|
+
source: ScoreInput[EventT] | None = None,
|
|
227
|
+
*,
|
|
228
|
+
pitch: Sequence[int] | None = None,
|
|
229
|
+
velocity: Sequence[int] | None = None,
|
|
230
|
+
duration: Sequence[int] | None = None,
|
|
231
|
+
channel: Sequence[int] | None = None,
|
|
232
|
+
start: int | None = None,
|
|
233
|
+
event_factory: EventFactory[EventT] | None = None,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Add events to the score using a convenience API.
|
|
237
|
+
|
|
238
|
+
This method supports either:
|
|
239
|
+
- a ScoreInput source, or
|
|
240
|
+
- zipped parameter lists with an event factory.
|
|
241
|
+
|
|
242
|
+
Notes
|
|
243
|
+
-----
|
|
244
|
+
This API is intentionally simple.
|
|
245
|
+
For explicit temporal structure, prefer
|
|
246
|
+
``add_timespans()``.
|
|
247
|
+
"""
|
|
248
|
+
ctx = self._context
|
|
249
|
+
|
|
250
|
+
if start is not None:
|
|
251
|
+
ctx = ScoreContext(cursor=start)
|
|
252
|
+
|
|
253
|
+
if source is not None:
|
|
254
|
+
events, ctx = source.iter_events(ctx)
|
|
255
|
+
self._events.extend(events)
|
|
256
|
+
self._context = ctx
|
|
257
|
+
self._dirty = True
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
if event_factory is None:
|
|
261
|
+
raise ValueError("event_factory must be provided when using zipped parameters")
|
|
262
|
+
|
|
263
|
+
kwargs = {
|
|
264
|
+
"pitch": pitch,
|
|
265
|
+
"velocity": velocity,
|
|
266
|
+
"duration": duration,
|
|
267
|
+
"channel": channel,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
source_ = ZippedNotes[EventT](
|
|
271
|
+
**{k: v for k, v in kwargs.items() if v is not None}, # type: ignore[arg-type]
|
|
272
|
+
event_factory=event_factory,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
events, ctx = source_.iter_events(ctx)
|
|
276
|
+
self._events.extend(events)
|
|
277
|
+
self._context = ctx
|
|
278
|
+
self._dirty = True
|
|
279
|
+
|
|
280
|
+
def add_timespans(
|
|
281
|
+
self,
|
|
282
|
+
spans: Iterable[TimeSpan],
|
|
283
|
+
*,
|
|
284
|
+
factory: Callable[[TimeSpan], EventT],
|
|
285
|
+
) -> None:
|
|
286
|
+
"""
|
|
287
|
+
Add events generated from explicit TimeSpans.
|
|
288
|
+
|
|
289
|
+
Score does not interpret TimeSpan contents.
|
|
290
|
+
It only stores the resulting events.
|
|
291
|
+
"""
|
|
292
|
+
for span in spans:
|
|
293
|
+
ev = factory(span)
|
|
294
|
+
self._events.append(ev)
|
|
295
|
+
|
|
296
|
+
self._dirty = True
|
|
297
|
+
|
|
298
|
+
# ---------------- query ----------------
|
|
299
|
+
|
|
300
|
+
def _ensure_sorted(self) -> None:
|
|
301
|
+
"""
|
|
302
|
+
Ensure events are sorted by start time.
|
|
303
|
+
"""
|
|
304
|
+
if self._dirty:
|
|
305
|
+
self._sorted_by_start = sorted(
|
|
306
|
+
self._events,
|
|
307
|
+
key=lambda e: e.span.start,
|
|
308
|
+
)
|
|
309
|
+
self._dirty = False
|
|
310
|
+
|
|
311
|
+
def events_between_span(self, span: TimeSpan) -> list[EventT]:
|
|
312
|
+
"""
|
|
313
|
+
Return events whose TimeSpan overlaps the given span.
|
|
314
|
+
"""
|
|
315
|
+
self._ensure_sorted()
|
|
316
|
+
|
|
317
|
+
if not self._sorted_by_start:
|
|
318
|
+
return []
|
|
319
|
+
|
|
320
|
+
result: list[EventT] = []
|
|
321
|
+
|
|
322
|
+
for e in self._sorted_by_start:
|
|
323
|
+
if e.span.overlaps(span):
|
|
324
|
+
result.append(e)
|
|
325
|
+
|
|
326
|
+
# optimization: early exit due to ordering
|
|
327
|
+
if e.span.start >= span.end:
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
def events_between(
|
|
333
|
+
self,
|
|
334
|
+
start: int = 0,
|
|
335
|
+
end: int | None = None,
|
|
336
|
+
) -> list[EventT]:
|
|
337
|
+
"""
|
|
338
|
+
Return events whose start time lies within the given time window.
|
|
339
|
+
|
|
340
|
+
Parameters
|
|
341
|
+
----------
|
|
342
|
+
start : int
|
|
343
|
+
Inclusive start position on the score time axis.
|
|
344
|
+
end : int or None
|
|
345
|
+
Exclusive end position.
|
|
346
|
+
If None, all events starting at or after ``start`` are returned.
|
|
347
|
+
|
|
348
|
+
Returns
|
|
349
|
+
-------
|
|
350
|
+
list of EventT
|
|
351
|
+
Events matching the given time range.
|
|
352
|
+
|
|
353
|
+
Notes
|
|
354
|
+
-----
|
|
355
|
+
- Time values are interpreted in the same unit used by TimeSpan
|
|
356
|
+
(typically milliseconds or ticks).
|
|
357
|
+
- This method filters by event start time, not full TimeSpan overlap.
|
|
358
|
+
For overlap-based queries, use ``events_between_span()``.
|
|
359
|
+
- The returned list is ordered by start time.
|
|
360
|
+
"""
|
|
361
|
+
if end is None:
|
|
362
|
+
self._ensure_sorted()
|
|
363
|
+
return [e for e in self._sorted_by_start if e.span.start >= start]
|
|
364
|
+
|
|
365
|
+
span = TimeSpan(
|
|
366
|
+
start=start,
|
|
367
|
+
duration=end - start,
|
|
368
|
+
)
|
|
369
|
+
return self.events_between_span(span)
|
hyperscore/core/time.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class TimeSpan:
|
|
8
|
+
"""
|
|
9
|
+
Immutable representation of a half-open time interval.
|
|
10
|
+
|
|
11
|
+
A TimeSpan represents a duration on a linear time axis,
|
|
12
|
+
measured in milliseconds (or any consistent time unit).
|
|
13
|
+
|
|
14
|
+
Attributes
|
|
15
|
+
----------
|
|
16
|
+
start : int
|
|
17
|
+
Inclusive start position.
|
|
18
|
+
duration : int
|
|
19
|
+
Non-negative duration.
|
|
20
|
+
|
|
21
|
+
Notes
|
|
22
|
+
-----
|
|
23
|
+
- The interval is half-open: [start, end)
|
|
24
|
+
- No ordering or global timeline is enforced.
|
|
25
|
+
- Musical interpretation is handled elsewhere.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
start: int
|
|
29
|
+
duration: int
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def end(self) -> int:
|
|
33
|
+
"""
|
|
34
|
+
Return the exclusive end position of the span.
|
|
35
|
+
"""
|
|
36
|
+
return self.start + self.duration
|
|
37
|
+
|
|
38
|
+
def shift(self, delta: int) -> TimeSpan:
|
|
39
|
+
"""
|
|
40
|
+
Return a new TimeSpan shifted along the time axis.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
delta : int
|
|
45
|
+
Time shift in milliseconds.
|
|
46
|
+
Positive values move the span forward,
|
|
47
|
+
negative values move it backward.
|
|
48
|
+
"""
|
|
49
|
+
return TimeSpan(self.start + delta, self.duration)
|
|
50
|
+
|
|
51
|
+
def stretch(self, factor: float) -> TimeSpan:
|
|
52
|
+
"""
|
|
53
|
+
Return a new TimeSpan with scaled duration.
|
|
54
|
+
|
|
55
|
+
The start position is preserved.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
factor : float
|
|
60
|
+
Duration scaling factor.
|
|
61
|
+
"""
|
|
62
|
+
return TimeSpan(self.start, round(self.duration * factor))
|
|
63
|
+
|
|
64
|
+
def overlaps(self, other: TimeSpan) -> bool:
|
|
65
|
+
"""
|
|
66
|
+
Return True if this span overlaps with another span.
|
|
67
|
+
"""
|
|
68
|
+
return not (self.end <= other.start or other.end <= self.start)
|
|
69
|
+
|
|
70
|
+
def contains(self, t: int) -> bool:
|
|
71
|
+
"""
|
|
72
|
+
Return True if the given time point lies within this span.
|
|
73
|
+
"""
|
|
74
|
+
return self.start <= t < self.end
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def bpm_to_ms(bpm: float, note_division: float = 1.0) -> float:
|
|
78
|
+
"""
|
|
79
|
+
Convert a musical duration at a given BPM into milliseconds.
|
|
80
|
+
|
|
81
|
+
This function performs a purely arithmetic conversion.
|
|
82
|
+
It does not assume any time signature or rhythmic structure.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
bpm : float
|
|
87
|
+
Tempo in beats per minute.
|
|
88
|
+
note_division : float, optional
|
|
89
|
+
Beat multiplier or subdivision.
|
|
90
|
+
For example:
|
|
91
|
+
- 1.0 = quarter note
|
|
92
|
+
- 0.5 = eighth note
|
|
93
|
+
- 2.0 = half note
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
float
|
|
98
|
+
Duration in milliseconds.
|
|
99
|
+
"""
|
|
100
|
+
return 60_000.0 / bpm * note_division
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from hyperscore.core.time import TimeSpan
|
|
7
|
+
|
|
8
|
+
TimeSpanTransform = Callable[[TimeSpan], TimeSpan | None]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class TimeSpanPipeline:
|
|
13
|
+
"""
|
|
14
|
+
Immutable pipeline for transforming TimeSpan objects.
|
|
15
|
+
|
|
16
|
+
A pipeline is a pure, composable sequence of TimeSpan
|
|
17
|
+
transformations. Each transform may either:
|
|
18
|
+
|
|
19
|
+
- return a modified TimeSpan
|
|
20
|
+
- return ``None`` to drop the span
|
|
21
|
+
|
|
22
|
+
Notes
|
|
23
|
+
-----
|
|
24
|
+
- The pipeline itself holds no state.
|
|
25
|
+
- No temporal ordering is enforced.
|
|
26
|
+
- This class does not generate TimeSpans; it only
|
|
27
|
+
transforms existing ones.
|
|
28
|
+
|
|
29
|
+
Typical use cases include:
|
|
30
|
+
- quantization
|
|
31
|
+
- clipping
|
|
32
|
+
- filtering by duration or position
|
|
33
|
+
- time-warping in the TimeSpan domain
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
transforms: tuple[TimeSpanTransform, ...] = ()
|
|
37
|
+
|
|
38
|
+
# ---------------- core ----------------
|
|
39
|
+
|
|
40
|
+
def apply(self, span: TimeSpan) -> TimeSpan | None:
|
|
41
|
+
"""
|
|
42
|
+
Apply the pipeline to a single TimeSpan.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
span : TimeSpan
|
|
47
|
+
Input TimeSpan.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
TimeSpan or None
|
|
52
|
+
Transformed TimeSpan, or None if dropped
|
|
53
|
+
by any transform.
|
|
54
|
+
"""
|
|
55
|
+
cur: TimeSpan | None = span
|
|
56
|
+
for t in self.transforms:
|
|
57
|
+
if cur is None:
|
|
58
|
+
return None
|
|
59
|
+
cur = t(cur)
|
|
60
|
+
return cur
|
|
61
|
+
|
|
62
|
+
def apply_all(self, spans: Iterable[TimeSpan]) -> list[TimeSpan]:
|
|
63
|
+
"""
|
|
64
|
+
Apply the pipeline to an iterable of TimeSpans.
|
|
65
|
+
|
|
66
|
+
TimeSpans dropped by the pipeline are excluded
|
|
67
|
+
from the result.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
spans : iterable of TimeSpan
|
|
72
|
+
Input TimeSpans.
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
list of TimeSpan
|
|
77
|
+
Transformed TimeSpans.
|
|
78
|
+
"""
|
|
79
|
+
out: list[TimeSpan] = []
|
|
80
|
+
for s in spans:
|
|
81
|
+
s2 = self.apply(s)
|
|
82
|
+
if s2 is not None:
|
|
83
|
+
out.append(s2)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
# ---------------- composition ----------------
|
|
87
|
+
|
|
88
|
+
def then(self, *more: TimeSpanTransform) -> TimeSpanPipeline:
|
|
89
|
+
"""
|
|
90
|
+
Return a new pipeline with additional transforms appended.
|
|
91
|
+
|
|
92
|
+
This method does not modify the original pipeline.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
*more : callable
|
|
97
|
+
Additional TimeSpan transforms.
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
TimeSpanPipeline
|
|
102
|
+
A new composed pipeline.
|
|
103
|
+
"""
|
|
104
|
+
return TimeSpanPipeline(self.transforms + more)
|
|
105
|
+
|
|
106
|
+
def __or__(self, other: TimeSpanPipeline) -> TimeSpanPipeline:
|
|
107
|
+
"""
|
|
108
|
+
Compose two pipelines using the ``|`` operator.
|
|
109
|
+
|
|
110
|
+
The resulting pipeline applies this pipeline first,
|
|
111
|
+
then the other pipeline.
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
TimeSpanPipeline
|
|
116
|
+
Composed pipeline.
|
|
117
|
+
"""
|
|
118
|
+
return TimeSpanPipeline(self.transforms + other.transforms)
|