subsequence 0.6.4__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.
- subsequence/__init__.py +231 -0
- subsequence/__main__.py +24 -0
- subsequence/assets/web/index.html +345 -0
- subsequence/cadences.py +113 -0
- subsequence/chord_graphs/__init__.py +100 -0
- subsequence/chord_graphs/aeolian_minor.py +158 -0
- subsequence/chord_graphs/chromatic_mediant.py +113 -0
- subsequence/chord_graphs/diminished.py +97 -0
- subsequence/chord_graphs/dorian_minor.py +127 -0
- subsequence/chord_graphs/functional_major.py +102 -0
- subsequence/chord_graphs/hooktheory_major.py +88 -0
- subsequence/chord_graphs/lydian_major.py +130 -0
- subsequence/chord_graphs/mixolydian.py +98 -0
- subsequence/chord_graphs/phrygian_minor.py +76 -0
- subsequence/chord_graphs/suspended.py +109 -0
- subsequence/chord_graphs/turnaround_global.py +157 -0
- subsequence/chord_graphs/whole_tone.py +77 -0
- subsequence/chords.py +419 -0
- subsequence/composition.py +6099 -0
- subsequence/conductor.py +238 -0
- subsequence/constants/__init__.py +24 -0
- subsequence/constants/durations.py +37 -0
- subsequence/constants/instruments/__init__.py +13 -0
- subsequence/constants/instruments/gm_cc.py +46 -0
- subsequence/constants/instruments/gm_drums.py +53 -0
- subsequence/constants/instruments/gm_instruments.py +32 -0
- subsequence/constants/instruments/roland_tr8s.py +320 -0
- subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
- subsequence/constants/midi_notes.py +29 -0
- subsequence/constants/pulses.py +17 -0
- subsequence/constants/velocity.py +22 -0
- subsequence/definitions.py +232 -0
- subsequence/display.py +617 -0
- subsequence/easing.py +347 -0
- subsequence/event_emitter.py +109 -0
- subsequence/form_state.py +665 -0
- subsequence/forms.py +257 -0
- subsequence/groove.py +323 -0
- subsequence/harmonic_rhythm.py +83 -0
- subsequence/harmonic_state.py +352 -0
- subsequence/harmony.py +197 -0
- subsequence/held_notes.py +91 -0
- subsequence/helpers/__init__.py +0 -0
- subsequence/helpers/network.py +58 -0
- subsequence/helpers/wing.py +430 -0
- subsequence/intervals.py +436 -0
- subsequence/keystroke.py +249 -0
- subsequence/link_clock.py +128 -0
- subsequence/live_client.py +187 -0
- subsequence/live_reloader.py +298 -0
- subsequence/live_server.py +161 -0
- subsequence/melodic_state.py +483 -0
- subsequence/midi.py +97 -0
- subsequence/midi_utils.py +329 -0
- subsequence/mini_notation.py +164 -0
- subsequence/motifs.py +2356 -0
- subsequence/osc.py +194 -0
- subsequence/pattern.py +363 -0
- subsequence/pattern_algorithmic.py +2010 -0
- subsequence/pattern_builder.py +2589 -0
- subsequence/pattern_midi.py +1208 -0
- subsequence/progressions.py +1913 -0
- subsequence/py.typed +0 -0
- subsequence/roles.py +63 -0
- subsequence/sequence_utils.py +3123 -0
- subsequence/sequencer.py +2086 -0
- subsequence/tuning.py +453 -0
- subsequence/voicings.py +151 -0
- subsequence/web_ui.py +337 -0
- subsequence/weighted_graph.py +156 -0
- subsequence-0.6.4.dist-info/METADATA +208 -0
- subsequence-0.6.4.dist-info/RECORD +78 -0
- subsequence-0.6.4.dist-info/WHEEL +5 -0
- subsequence-0.6.4.dist-info/entry_points.txt +2 -0
- subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
- subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
- subsequence-0.6.4.dist-info/scm_version.json +8 -0
- subsequence-0.6.4.dist-info/top_level.txt +1 -0
subsequence/easing.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Easing functions for transitions and ramps.
|
|
2
|
+
|
|
3
|
+
Easing functions map a normalised progress value *t* in [0, 1] to an eased
|
|
4
|
+
output in [0, 1]. They are used by ``conductor.line()``, ``target_bpm()``,
|
|
5
|
+
``cc_ramp()``, and ``pitch_bend_ramp()`` to shape how a value moves from a
|
|
6
|
+
start to an end over time.
|
|
7
|
+
|
|
8
|
+
Pass a name string or a plain callable to any ``shape`` parameter:
|
|
9
|
+
|
|
10
|
+
composition.conductor.line("filter", 0, 1, 64, shape="ease_in_out")
|
|
11
|
+
composition.target_bpm(140, bars=8, shape="s_curve")
|
|
12
|
+
p.cc_ramp(74, 0, 127, shape="exponential")
|
|
13
|
+
|
|
14
|
+
# Custom callable — receives and returns a float in [0, 1]:
|
|
15
|
+
p.cc_ramp(74, 0, 127, shape=lambda t: t ** 0.5)
|
|
16
|
+
|
|
17
|
+
Available shapes:
|
|
18
|
+
|
|
19
|
+
"linear" Constant rate (default).
|
|
20
|
+
"ease_in" Slow start, accelerates — fade-ins, building tension.
|
|
21
|
+
"ease_out" Fast start, decelerates — fade-outs, natural decay.
|
|
22
|
+
"ease_in_out" Smooth S-curve (Hermite smoothstep) — BPM changes, crossfades.
|
|
23
|
+
"exponential" Very slow start, rapid end (cubic) — filter sweeps.
|
|
24
|
+
"logarithmic" Rapid start, very gradual end (cubic) — volume fades.
|
|
25
|
+
"s_curve" Smoother S-curve (Perlin smootherstep) — long, gentle transitions.
|
|
26
|
+
|
|
27
|
+
All functions satisfy f(0) = 0 and f(1) = 1 and are monotonically non-decreasing.
|
|
28
|
+
Input outside [0, 1] is not defined.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import typing
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ─── Easing functions ─────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def linear (t: float) -> float:
|
|
40
|
+
"""No transformation — constant rate of change."""
|
|
41
|
+
return t
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ease_in (t: float) -> float:
|
|
45
|
+
"""Quadratic ease-in: slow start, accelerates toward the end."""
|
|
46
|
+
return t * t
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def ease_out (t: float) -> float:
|
|
50
|
+
"""Quadratic ease-out: fast start, decelerates toward the end."""
|
|
51
|
+
return 1.0 - (1.0 - t) * (1.0 - t)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ease_in_out (t: float) -> float:
|
|
55
|
+
"""Hermite smoothstep S-curve: smooth start and end, faster in the middle."""
|
|
56
|
+
return t * t * (3.0 - 2.0 * t)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def exponential (t: float) -> float:
|
|
60
|
+
"""Cubic ease-in: very slow start with rapid acceleration.
|
|
61
|
+
|
|
62
|
+
Approximates a perceptually linear response for audio parameters like
|
|
63
|
+
filter cutoff, where the human ear's logarithmic sensitivity means a
|
|
64
|
+
slow early ramp sounds more even.
|
|
65
|
+
"""
|
|
66
|
+
return t * t * t
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def logarithmic (t: float) -> float:
|
|
70
|
+
"""Cubic ease-out: rapid initial change that tapers to a gradual end.
|
|
71
|
+
|
|
72
|
+
Useful for decay shapes and volume fades where most of the audible change
|
|
73
|
+
happens early and the tail fades imperceptibly.
|
|
74
|
+
"""
|
|
75
|
+
return 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def s_curve (t: float) -> float:
|
|
79
|
+
"""Perlin smootherstep: a smoother S-curve than ease_in_out.
|
|
80
|
+
|
|
81
|
+
Has zero first *and* second derivatives at t=0 and t=1, eliminating the
|
|
82
|
+
subtle acceleration jerk at the boundaries. Best for long, slow
|
|
83
|
+
transitions where the smoothness is perceptible.
|
|
84
|
+
"""
|
|
85
|
+
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ─── Registry and lookup ──────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
EasingFn = typing.Callable[[float], float]
|
|
91
|
+
|
|
92
|
+
EASING_FUNCTIONS: typing.Dict[str, EasingFn] = {
|
|
93
|
+
"linear": linear,
|
|
94
|
+
"ease_in": ease_in,
|
|
95
|
+
"ease_out": ease_out,
|
|
96
|
+
"ease_in_out": ease_in_out,
|
|
97
|
+
"exponential": exponential,
|
|
98
|
+
"logarithmic": logarithmic,
|
|
99
|
+
"s_curve": s_curve,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_easing (shape: typing.Union[str, EasingFn]) -> EasingFn:
|
|
104
|
+
"""Return the easing function for *shape*.
|
|
105
|
+
|
|
106
|
+
*shape* may be a name string (see :data:`EASING_FUNCTIONS`) or any
|
|
107
|
+
callable that maps a float in [0, 1] to a float in [0, 1].
|
|
108
|
+
|
|
109
|
+
Raises :class:`ValueError` for unknown string names.
|
|
110
|
+
"""
|
|
111
|
+
if callable(shape):
|
|
112
|
+
return shape
|
|
113
|
+
if shape not in EASING_FUNCTIONS:
|
|
114
|
+
available = ", ".join(f'"{k}"' for k in sorted(EASING_FUNCTIONS))
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Unknown easing shape {shape!r}. Available shapes: {available}"
|
|
117
|
+
)
|
|
118
|
+
return EASING_FUNCTIONS[shape]
|
|
119
|
+
|
|
120
|
+
def map_value (
|
|
121
|
+
value: float,
|
|
122
|
+
in_min: float = 0.0,
|
|
123
|
+
in_max: float = 1.0,
|
|
124
|
+
out_min: float = 0.0,
|
|
125
|
+
out_max: float = 1.0,
|
|
126
|
+
shape: typing.Union[str, EasingFn] = "linear",
|
|
127
|
+
clamp: bool = True
|
|
128
|
+
) -> float:
|
|
129
|
+
"""Map a value from an input range to an output range, with optional easing.
|
|
130
|
+
|
|
131
|
+
Linearly maps *value* from the range ``[in_min, in_max]`` into a normalised
|
|
132
|
+
progress ratio [0.0, 1.0], applies the designated easing curve, and
|
|
133
|
+
interpolates that eased ratio into the output range ``[out_min, out_max]``.
|
|
134
|
+
|
|
135
|
+
This is particularly useful for musically scaling raw generative outputs
|
|
136
|
+
(which usually fall between 0.0 and 1.0) into MIDI ranges like pitch or
|
|
137
|
+
velocity, while automatically applying musical volume or tension curves.
|
|
138
|
+
|
|
139
|
+
Parameters:
|
|
140
|
+
value: The raw input to scale.
|
|
141
|
+
in_min: The lower bound of the input's expected range.
|
|
142
|
+
in_max: The upper bound of the input's expected range.
|
|
143
|
+
out_min: The lower bound of the mapped output range.
|
|
144
|
+
out_max: The upper bound of the mapped output range.
|
|
145
|
+
shape: The easing curve to apply to the mapped ratio before
|
|
146
|
+
outputting (e.g. \"linear\", \"ease_in_out\"). See
|
|
147
|
+
:func:`get_easing` for all available shapes.
|
|
148
|
+
clamp: If True (the default), values outside the input range
|
|
149
|
+
will be clamped so they never exceed the output bounds.
|
|
150
|
+
Essential for ensuring MIDI values don't break valid ranges.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The mapped and eased value as a float.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
if in_min == in_max:
|
|
157
|
+
return out_min
|
|
158
|
+
|
|
159
|
+
# 1. Normalise to [0.0, 1.0]
|
|
160
|
+
t = (value - in_min) / (in_max - in_min)
|
|
161
|
+
|
|
162
|
+
# 2. Clamp
|
|
163
|
+
if clamp:
|
|
164
|
+
t = max(0.0, min(1.0, t))
|
|
165
|
+
|
|
166
|
+
# 3. Apply easing curve
|
|
167
|
+
eased_t = get_easing(shape)(t)
|
|
168
|
+
|
|
169
|
+
# 4. Map to output range
|
|
170
|
+
return out_min + (out_max - out_min) * eased_t
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ─── Batch helpers ────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def ramp (
|
|
177
|
+
n: int,
|
|
178
|
+
low: float = 0.0,
|
|
179
|
+
high: float = 1.0,
|
|
180
|
+
shape: typing.Union[str, EasingFn] = "linear",
|
|
181
|
+
) -> typing.List[float]:
|
|
182
|
+
|
|
183
|
+
"""Return a list of *n* values eased from *low* to *high*.
|
|
184
|
+
|
|
185
|
+
This is the batch equivalent of :func:`map_value` for the common case
|
|
186
|
+
of generating a fixed-length sequence that sweeps from one value to
|
|
187
|
+
another. Useful for velocity ramps, density envelopes, filter sweeps,
|
|
188
|
+
or any per-step value that should follow a curve.
|
|
189
|
+
|
|
190
|
+
Parameters:
|
|
191
|
+
n: Number of values to generate.
|
|
192
|
+
low: The value at the first step (t = 0).
|
|
193
|
+
high: The value at the last step (t = 1).
|
|
194
|
+
shape: Easing curve name or callable (see :data:`EASING_FUNCTIONS`).
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
``List[float]`` of length *n*. For MIDI velocity use, cast to int:
|
|
198
|
+
``[int(v) for v in easing.ramp(p.grid, 50, 100, "ease_in_out")]``
|
|
199
|
+
|
|
200
|
+
Example::
|
|
201
|
+
|
|
202
|
+
# Snare roll that swells into a hit
|
|
203
|
+
velocities = [int(v) for v in easing.ramp(p.grid, 30, 100, "ease_in")]
|
|
204
|
+
p.sequence(steps=range(16), pitches="snare_1", velocities=velocities)
|
|
205
|
+
|
|
206
|
+
# Ghost fill velocity envelope passed directly (float values accepted)
|
|
207
|
+
p.ghost_fill("snare_1", 1, velocity=easing.ramp(p.grid, 20, 80, "ease_out"),
|
|
208
|
+
bias="sixteenths", no_overlap=True)
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
fn = get_easing(shape)
|
|
212
|
+
if n == 1:
|
|
213
|
+
return [float(low)]
|
|
214
|
+
return [low + (high - low) * fn(i / (n - 1)) for i in range(n)]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ─── Stateful interpolation ───────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class EasedValue:
|
|
221
|
+
|
|
222
|
+
"""Smoothly interpolates between discrete data updates.
|
|
223
|
+
|
|
224
|
+
When external data arrives in snapshots — API polls, sensor readings,
|
|
225
|
+
OSC messages — jumping instantly to each new value often sounds jarring.
|
|
226
|
+
``EasedValue`` remembers the previous value and provides a smooth,
|
|
227
|
+
eased interpolation to the new one over a normalised progress window.
|
|
228
|
+
|
|
229
|
+
A typical use-case is a ``composition.schedule()`` function that writes
|
|
230
|
+
to ``composition.data`` every *N* bars, paired with a pattern that reads
|
|
231
|
+
the smoothed value on every rebuild:
|
|
232
|
+
|
|
233
|
+
Example::
|
|
234
|
+
|
|
235
|
+
# Module level: create one per data field you want to smooth.
|
|
236
|
+
iss_lat = subsequence.easing.EasedValue(initial=0.5)
|
|
237
|
+
|
|
238
|
+
# Scheduled task (fires every 16 bars):
|
|
239
|
+
def fetch_data (p):
|
|
240
|
+
new_lat = get_latest_latitude() # 0.0–1.0
|
|
241
|
+
iss_lat.update(new_lat)
|
|
242
|
+
|
|
243
|
+
# Pattern (rebuilds every bar). 16-bar cycle matches the schedule.
|
|
244
|
+
@composition.pattern(channel=0, length=4)
|
|
245
|
+
def drums (p):
|
|
246
|
+
progress = (p.cycle % 16) / 16 # 0 → 1 over one fetch cycle
|
|
247
|
+
velocity = int(100 * iss_lat.get(progress))
|
|
248
|
+
p.hit_steps("kick_1", range(16), velocity=velocity)
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
initial: Optional starting value. If provided, the first call to
|
|
252
|
+
:meth:`update` will ease from this initial value. If omitted,
|
|
253
|
+
the first call to :meth:`update` will instantly set both the
|
|
254
|
+
*previous* and *current* values to the new target, preventing
|
|
255
|
+
an unintended transition from a default value.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def __init__ (self, initial: typing.Optional[float] = None) -> None:
|
|
259
|
+
|
|
260
|
+
# We keep the internal float fields strictly non-Optional (defaulting to 0.0)
|
|
261
|
+
# to guarantee mypy safety and branch-free math in get() and delta. The
|
|
262
|
+
# _has_updated flag abstracts the "first-update" logic away safely.
|
|
263
|
+
val = initial if initial is not None else 0.0
|
|
264
|
+
self._prev: float = val
|
|
265
|
+
self._current: float = val
|
|
266
|
+
self._has_updated: bool = initial is not None
|
|
267
|
+
|
|
268
|
+
def update (self, value: float) -> None:
|
|
269
|
+
|
|
270
|
+
"""Accept a new target value.
|
|
271
|
+
|
|
272
|
+
The current value becomes the new *previous* baseline, and
|
|
273
|
+
*value* becomes the target that :meth:`get` interpolates toward.
|
|
274
|
+
If no ``initial`` value was provided at construction, the very first
|
|
275
|
+
call to this method sets both *previous* and *current* to *value*.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
value: The new target, typically a normalised float in [0, 1]
|
|
279
|
+
(though any numeric range is accepted as long as consumers
|
|
280
|
+
interpret it consistently).
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
if not self._has_updated:
|
|
284
|
+
self._prev = value
|
|
285
|
+
self._current = value
|
|
286
|
+
self._has_updated = True
|
|
287
|
+
else:
|
|
288
|
+
self._prev = self._current
|
|
289
|
+
self._current = value
|
|
290
|
+
|
|
291
|
+
def get (
|
|
292
|
+
self,
|
|
293
|
+
progress: float,
|
|
294
|
+
shape: typing.Union[str, EasingFn] = "ease_in_out",
|
|
295
|
+
) -> float:
|
|
296
|
+
|
|
297
|
+
"""Return the interpolated value at *progress* through the transition.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
progress: How far through the current transition, in [0, 1].
|
|
301
|
+
``0.0`` returns the previous value; ``1.0`` returns the
|
|
302
|
+
current target. Typically computed as
|
|
303
|
+
``(p.cycle % N) / N`` where *N* is the number of pattern
|
|
304
|
+
cycles per data-fetch cycle.
|
|
305
|
+
shape: Easing shape name (see :data:`EASING_FUNCTIONS`) or a
|
|
306
|
+
callable ``f(t) -> t`` in [0, 1]. Defaults to
|
|
307
|
+
``"ease_in_out"`` (Hermite smoothstep).
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
The interpolated float between the previous and current value.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
eased = get_easing(shape)(progress)
|
|
314
|
+
return self._prev + (self._current - self._prev) * eased
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def current (self) -> float:
|
|
318
|
+
"""The most recently set target value (after the last :meth:`update`)."""
|
|
319
|
+
return self._current
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def previous (self) -> float:
|
|
323
|
+
"""The value that was current before the last :meth:`update`."""
|
|
324
|
+
return self._prev
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def delta (self) -> float:
|
|
328
|
+
"""Signed change between the previous and current value.
|
|
329
|
+
|
|
330
|
+
Positive means the value rose on the last :meth:`update`; negative
|
|
331
|
+
means it fell; zero means it was unchanged. The magnitude reflects
|
|
332
|
+
the size of the jump.
|
|
333
|
+
|
|
334
|
+
This property is constant across all pattern rebuilds within one fetch
|
|
335
|
+
cycle, making it straightforward to branch on direction without
|
|
336
|
+
worrying about sample-level fluctuations:
|
|
337
|
+
|
|
338
|
+
Example::
|
|
339
|
+
|
|
340
|
+
# Choose arpeggio direction based on which way the value is moving.
|
|
341
|
+
direction = "up" if iss_lat.delta >= 0 else "down"
|
|
342
|
+
p.arpeggio(pitches, spacing=0.25, direction=direction)
|
|
343
|
+
|
|
344
|
+
# Scale an effect by how large the change was.
|
|
345
|
+
urgency = abs(iss_lat.delta) # 0.0 = stable, larger = big jump
|
|
346
|
+
"""
|
|
347
|
+
return self._current - self._prev
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
CallbackType = typing.Callable[..., typing.Any]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventEmitter:
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
A simple event emitter supporting sync and async callbacks.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__ (self) -> None:
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
Initialize an empty event registry.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
self._listeners: typing.Dict[str, typing.List[CallbackType]] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def on (self, event_name: str, callback: CallbackType) -> None:
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
Register a callback for an event name.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
if event_name not in self._listeners:
|
|
34
|
+
self._listeners[event_name] = []
|
|
35
|
+
|
|
36
|
+
self._listeners[event_name].append(callback)
|
|
37
|
+
|
|
38
|
+
def off (self, event_name: str, callback: CallbackType) -> None:
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
Unregister a previously registered callback.
|
|
42
|
+
|
|
43
|
+
Raises ``ValueError`` if the callback is not registered for the event.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
if event_name not in self._listeners or callback not in self._listeners[event_name]:
|
|
47
|
+
raise ValueError(f"Callback not registered for event {event_name!r}")
|
|
48
|
+
|
|
49
|
+
self._listeners[event_name].remove(callback)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def emit_sync (self, event_name: str, *args: typing.Any, **kwargs: typing.Any) -> None:
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
Emit an event and call non-async listeners immediately.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
if event_name not in self._listeners:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
for callback in self._listeners[event_name]:
|
|
62
|
+
|
|
63
|
+
if inspect.iscoroutinefunction(callback):
|
|
64
|
+
raise ValueError("Async callback encountered in emit_sync")
|
|
65
|
+
|
|
66
|
+
result = callback(*args, **kwargs)
|
|
67
|
+
|
|
68
|
+
# Catch async-callable objects too (async __call__ fails the
|
|
69
|
+
# iscoroutinefunction check but still returns an awaitable).
|
|
70
|
+
if inspect.isawaitable(result):
|
|
71
|
+
typing.cast(typing.Coroutine, result).close()
|
|
72
|
+
raise ValueError("Async callback encountered in emit_sync")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def emit_async (self, event_name: str, *args: typing.Any, **kwargs: typing.Any) -> None:
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
Emit an event, awaiting async listeners.
|
|
79
|
+
|
|
80
|
+
One raising listener never silences the others: sync exceptions are
|
|
81
|
+
logged and the remaining listeners still run, and async listeners are
|
|
82
|
+
gathered with their exceptions logged individually.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
if event_name not in self._listeners:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
tasks: typing.List[typing.Awaitable[typing.Any]] = []
|
|
89
|
+
|
|
90
|
+
for callback in self._listeners[event_name]:
|
|
91
|
+
|
|
92
|
+
# Calling first and checking the RESULT handles both coroutine
|
|
93
|
+
# functions and async-callable objects (async __call__), which
|
|
94
|
+
# iscoroutinefunction misses.
|
|
95
|
+
try:
|
|
96
|
+
result = callback(*args, **kwargs)
|
|
97
|
+
except Exception:
|
|
98
|
+
logger.exception("Listener for %r raised - continuing with remaining listeners", event_name)
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if inspect.isawaitable(result):
|
|
102
|
+
tasks.append(result)
|
|
103
|
+
|
|
104
|
+
if tasks:
|
|
105
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
106
|
+
|
|
107
|
+
for outcome in results:
|
|
108
|
+
if isinstance(outcome, BaseException):
|
|
109
|
+
logger.error("Async listener for %r raised", event_name, exc_info=outcome)
|