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 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
+ ]
@@ -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)
@@ -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)