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/web_ui.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser dashboard for a running composition.
|
|
3
|
+
|
|
4
|
+
Serves a read-only web UI that shows the live state of a composition —
|
|
5
|
+
tempo, current chord, section, pattern grids, and conductor signals —
|
|
6
|
+
over a local HTTP + WebSocket pair. Started via ``composition.web_ui()``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import http.server
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import socketserver
|
|
15
|
+
import threading
|
|
16
|
+
import traceback
|
|
17
|
+
import typing
|
|
18
|
+
import weakref
|
|
19
|
+
|
|
20
|
+
import websockets
|
|
21
|
+
import websockets.asyncio.server
|
|
22
|
+
import websockets.exceptions
|
|
23
|
+
|
|
24
|
+
import subsequence.helpers.network
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
class WebUI:
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
Background Web UI Server.
|
|
32
|
+
Delivers composition state to connected web clients via WebSockets without
|
|
33
|
+
blocking the audio loop, and serves the static frontend assets via HTTP.
|
|
34
|
+
|
|
35
|
+
Both servers bind to localhost (127.0.0.1) by default. Pass an explicit
|
|
36
|
+
``http_host`` / ``ws_host`` (e.g. "0.0.0.0") to opt into LAN exposure: the
|
|
37
|
+
dashboard is read-only (inbound WebSocket messages are discarded) but it
|
|
38
|
+
broadcasts full composition state, so only expose it on a trusted network.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__ (self, composition: typing.Any, http_port: int = 8080, ws_port: int = 8765, ws_host: str = "127.0.0.1", http_host: str = "127.0.0.1") -> None:
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
Prepare the dashboard servers without starting them; call start() to go live.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
self.composition_ref = weakref.ref(composition)
|
|
48
|
+
self.http_port = http_port
|
|
49
|
+
self.ws_port = ws_port
|
|
50
|
+
self.ws_host = ws_host
|
|
51
|
+
self.http_host = http_host
|
|
52
|
+
self._http_thread: typing.Optional[threading.Thread] = None
|
|
53
|
+
self._httpd: typing.Optional[socketserver.TCPServer] = None
|
|
54
|
+
self._ws_server: typing.Optional[websockets.asyncio.server.Server] = None
|
|
55
|
+
self._broadcast_task: typing.Optional[asyncio.Task] = None
|
|
56
|
+
self._last_state: typing.Optional[typing.Dict[str, typing.Any]] = None
|
|
57
|
+
self._clients: typing.Set[websockets.asyncio.server.ServerConnection] = set()
|
|
58
|
+
self._last_bar: int = -1
|
|
59
|
+
self._cached_patterns: typing.List[typing.Dict[str, typing.Any]] = []
|
|
60
|
+
|
|
61
|
+
def start (self) -> None:
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
Launch the dashboard: HTTP server for the frontend, WebSocket server for live state.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
self._start_http_server()
|
|
68
|
+
# Keep a reference: an unreferenced task may be garbage-collected
|
|
69
|
+
# before it completes (asyncio docs).
|
|
70
|
+
self._ws_bootstrap_task = asyncio.create_task(self._start_ws_server())
|
|
71
|
+
|
|
72
|
+
def _start_http_server (self) -> None:
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
Serve the static dashboard assets on a daemon thread and log the URLs to visit.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
if self._http_thread and self._http_thread.is_alive():
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
web_dir = os.path.join(os.path.dirname(__file__), "assets", "web")
|
|
82
|
+
if not os.path.exists(web_dir):
|
|
83
|
+
os.makedirs(web_dir, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
ws_port = self.ws_port
|
|
86
|
+
|
|
87
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
88
|
+
def __init__ (self, *args: typing.Any, **kwargs: typing.Any) -> None:
|
|
89
|
+
super().__init__(*args, directory=web_dir, **kwargs)
|
|
90
|
+
def log_message (self, format: str, *args: typing.Any) -> None:
|
|
91
|
+
pass # Suppress HTTP access logging to keep the console clean
|
|
92
|
+
def do_GET (self) -> None:
|
|
93
|
+
# Serve the dashboard with the real websocket port substituted —
|
|
94
|
+
# the page hardcoding 8765 made WebUI(ws_port=...) a dashboard
|
|
95
|
+
# that could never connect.
|
|
96
|
+
if self.path in ("/", "/index.html"):
|
|
97
|
+
try:
|
|
98
|
+
with open(os.path.join(web_dir, "index.html"), "r", encoding="utf-8") as fh:
|
|
99
|
+
page = fh.read().replace("__WS_PORT__", str(ws_port))
|
|
100
|
+
except OSError:
|
|
101
|
+
self.send_error(404)
|
|
102
|
+
return
|
|
103
|
+
body = page.encode("utf-8")
|
|
104
|
+
self.send_response(200)
|
|
105
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
106
|
+
self.send_header("Content-Length", str(len(body)))
|
|
107
|
+
self.end_headers()
|
|
108
|
+
self.wfile.write(body)
|
|
109
|
+
return
|
|
110
|
+
super().do_GET()
|
|
111
|
+
|
|
112
|
+
# Bind on the main thread so stop() has a reference to shut the server
|
|
113
|
+
# down cleanly (serve_forever runs on the worker thread below). Localhost
|
|
114
|
+
# by default — see the class docstring for LAN-exposure guidance.
|
|
115
|
+
# Subclass rather than mutating the TCPServer CLASS attribute, which
|
|
116
|
+
# would change behaviour for every TCPServer in the process.
|
|
117
|
+
class _ReusableTCPServer (socketserver.TCPServer):
|
|
118
|
+
allow_reuse_address = True
|
|
119
|
+
self._httpd = _ReusableTCPServer((self.http_host, self.http_port), Handler)
|
|
120
|
+
|
|
121
|
+
def run_server () -> None:
|
|
122
|
+
try:
|
|
123
|
+
assert self._httpd is not None
|
|
124
|
+
self._httpd.serve_forever()
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.error(f"HTTP Server error: {e}")
|
|
127
|
+
|
|
128
|
+
self._http_thread = threading.Thread(target=run_server, daemon=True)
|
|
129
|
+
self._http_thread.start()
|
|
130
|
+
|
|
131
|
+
local_ip = subsequence.helpers.network.get_local_ip()
|
|
132
|
+
urls = [f"http://localhost:{self.http_port}"]
|
|
133
|
+
# If a distinct LAN IP was discovered, add the standard loopback and the LAN IP
|
|
134
|
+
if local_ip != "127.0.0.1":
|
|
135
|
+
urls.append(f"http://127.0.0.1:{self.http_port}")
|
|
136
|
+
urls.append(f"http://{local_ip}:{self.http_port}")
|
|
137
|
+
|
|
138
|
+
logger.info("Web UI Dashboard available at:\n " + "\n ".join(urls))
|
|
139
|
+
|
|
140
|
+
async def _handle_client (self, websocket: websockets.asyncio.server.ServerConnection) -> None:
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
Track a connected browser for broadcasts; incoming messages are discarded (read-only UI).
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
self._clients.add(websocket)
|
|
147
|
+
try:
|
|
148
|
+
# We don't process incoming commands in the PoC, just keep alive
|
|
149
|
+
# and listen to keep the connection open cleanly.
|
|
150
|
+
async for _message in websocket:
|
|
151
|
+
pass
|
|
152
|
+
except websockets.exceptions.ConnectionClosed:
|
|
153
|
+
pass
|
|
154
|
+
finally:
|
|
155
|
+
self._clients.remove(websocket)
|
|
156
|
+
|
|
157
|
+
async def _start_ws_server (self) -> None:
|
|
158
|
+
|
|
159
|
+
"""
|
|
160
|
+
Open the WebSocket endpoint and kick off the periodic state broadcast.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
self._ws_server = await websockets.asyncio.server.serve(self._handle_client, self.ws_host, self.ws_port)
|
|
165
|
+
self._broadcast_task = asyncio.create_task(self._broadcast_loop())
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.error(f"WebSocket server error: {e}")
|
|
168
|
+
|
|
169
|
+
async def _broadcast_loop (self) -> None:
|
|
170
|
+
|
|
171
|
+
"""
|
|
172
|
+
Push composition state to all connected browsers 10x/sec, skipping unchanged frames.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
while True:
|
|
176
|
+
# Broadcast 10 times a second to keep UI snappy without bogging down the loop
|
|
177
|
+
await asyncio.sleep(0.1)
|
|
178
|
+
|
|
179
|
+
if not self._clients:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
comp = self.composition_ref()
|
|
183
|
+
if comp is None:
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
state = self._get_state(comp)
|
|
188
|
+
|
|
189
|
+
# Serialising the full note set 10x/sec on the audio loop is
|
|
190
|
+
# avoidable jitter - skip when nothing changed since last send.
|
|
191
|
+
if state == self._last_state:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
self._last_state = state
|
|
195
|
+
message = json.dumps(state)
|
|
196
|
+
websockets.broadcast(self._clients.copy(), message)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error(f"Error broadcasting UI state: {e}\n{traceback.format_exc()}")
|
|
199
|
+
|
|
200
|
+
def _get_state (self, comp: typing.Any) -> typing.Dict[str, typing.Any]:
|
|
201
|
+
|
|
202
|
+
"""
|
|
203
|
+
Snapshot the musical state of the composition (tempo, chord, section, patterns, signals) as a JSON-ready dict.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
state: typing.Dict[str, typing.Any] = {
|
|
207
|
+
# The LIVE tempo — comp.bpm is the declared value and freezes
|
|
208
|
+
# during target_bpm ramps, clock-follow, and Link tempo changes.
|
|
209
|
+
"bpm": comp.sequencer.current_bpm if comp.sequencer else comp.bpm,
|
|
210
|
+
"section": None,
|
|
211
|
+
"chord": None,
|
|
212
|
+
"patterns": [],
|
|
213
|
+
"signals": {},
|
|
214
|
+
"playhead_pulse": 0,
|
|
215
|
+
"pulses_per_beat": 24,
|
|
216
|
+
"key": comp.key,
|
|
217
|
+
"section_bar": None,
|
|
218
|
+
"section_bars": None,
|
|
219
|
+
"next_section": None,
|
|
220
|
+
"global_bar": 0,
|
|
221
|
+
"global_beat": 0
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if comp.sequencer:
|
|
225
|
+
state["playhead_pulse"] = comp.sequencer.pulse_count
|
|
226
|
+
state["pulses_per_beat"] = comp.sequencer.pulses_per_beat
|
|
227
|
+
state["global_bar"] = max(0, comp.sequencer.current_bar) + 1
|
|
228
|
+
state["global_beat"] = max(0, comp.sequencer.current_beat) + 1
|
|
229
|
+
|
|
230
|
+
if comp.form_state:
|
|
231
|
+
section_info = comp.form_state.get_section_info()
|
|
232
|
+
if section_info:
|
|
233
|
+
state["section"] = section_info.name
|
|
234
|
+
state["section_bar"] = section_info.bar + 1
|
|
235
|
+
state["section_bars"] = section_info.bars
|
|
236
|
+
state["next_section"] = section_info.next_section
|
|
237
|
+
|
|
238
|
+
if comp.harmonic_state and comp.harmonic_state.current_chord:
|
|
239
|
+
state["chord"] = comp.harmonic_state.current_chord.name()
|
|
240
|
+
|
|
241
|
+
# Refresh pattern grid only when the bar changes, so the visual update
|
|
242
|
+
# is synced to when the pattern *starts playing*, not when it's rebuilt
|
|
243
|
+
# (which happens one lookahead beat early).
|
|
244
|
+
current_bar = state["global_bar"]
|
|
245
|
+
if current_bar != self._last_bar:
|
|
246
|
+
self._last_bar = current_bar
|
|
247
|
+
self._cached_patterns = []
|
|
248
|
+
for name, pattern in comp.running_patterns.items():
|
|
249
|
+
pattern_data: typing.Dict[str, typing.Any] = {
|
|
250
|
+
"name": name,
|
|
251
|
+
"muted": getattr(pattern, "_muted", False),
|
|
252
|
+
"length_pulses": int(pattern.length * state["pulses_per_beat"]),
|
|
253
|
+
"drum_map": getattr(pattern, "_drum_note_map", None),
|
|
254
|
+
"notes": []
|
|
255
|
+
}
|
|
256
|
+
if hasattr(pattern, "steps"):
|
|
257
|
+
for pulse, step in pattern.steps.items():
|
|
258
|
+
for note in getattr(step, "notes", []):
|
|
259
|
+
pattern_data["notes"].append({
|
|
260
|
+
"p": note.pitch,
|
|
261
|
+
"s": pulse,
|
|
262
|
+
"d": note.duration,
|
|
263
|
+
"v": note.velocity
|
|
264
|
+
})
|
|
265
|
+
self._cached_patterns.append(pattern_data)
|
|
266
|
+
state["patterns"] = self._cached_patterns
|
|
267
|
+
|
|
268
|
+
def _extract_val (val: typing.Any) -> typing.Optional[float]:
|
|
269
|
+
if hasattr(val, "current"): # Matches EasedValue
|
|
270
|
+
try:
|
|
271
|
+
return float(val.current)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.debug(f"WebUI failed to extract float from .current on {val}: {e}")
|
|
274
|
+
if callable(getattr(val, "value", None)):
|
|
275
|
+
try:
|
|
276
|
+
return float(val.value())
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.debug(f"WebUI failed to extract float from .value() on {val}: {e}")
|
|
279
|
+
elif hasattr(val, "value"):
|
|
280
|
+
try:
|
|
281
|
+
return float(val.value)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
logger.debug(f"WebUI failed to extract float from .value on {val}: {e}")
|
|
284
|
+
elif type(val) in (int, float, bool):
|
|
285
|
+
return float(val)
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
# Extract from conductor
|
|
289
|
+
if comp.conductor:
|
|
290
|
+
beat_time = comp.sequencer.pulse_count / comp.sequencer.pulses_per_beat if comp.sequencer else 0.0
|
|
291
|
+
for name, signal in comp.conductor._signals.items():
|
|
292
|
+
try:
|
|
293
|
+
state["signals"][name] = float(signal.value_at(beat_time))
|
|
294
|
+
except Exception as e:
|
|
295
|
+
# A raising signal should be diagnosable, not vanish from
|
|
296
|
+
# the dashboard (same treatment as _extract_val below).
|
|
297
|
+
logger.debug(f"WebUI failed to read conductor signal '{name}': {e}")
|
|
298
|
+
|
|
299
|
+
# Extract from composition data dictionary
|
|
300
|
+
for name, val in comp.data.items():
|
|
301
|
+
extracted = _extract_val(val)
|
|
302
|
+
if extracted is not None:
|
|
303
|
+
state["signals"][name] = extracted
|
|
304
|
+
|
|
305
|
+
return state
|
|
306
|
+
|
|
307
|
+
def stop (self) -> None:
|
|
308
|
+
|
|
309
|
+
"""
|
|
310
|
+
Shut down both servers cleanly so ports and threads don't leak.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
if self._broadcast_task:
|
|
314
|
+
self._broadcast_task.cancel()
|
|
315
|
+
self._broadcast_task = None
|
|
316
|
+
|
|
317
|
+
if self._ws_server:
|
|
318
|
+
self._ws_server.close()
|
|
319
|
+
# If the event loop is still running, await the shutdown
|
|
320
|
+
try:
|
|
321
|
+
loop = asyncio.get_running_loop()
|
|
322
|
+
loop.create_task(self._ws_server.wait_closed())
|
|
323
|
+
except RuntimeError:
|
|
324
|
+
pass
|
|
325
|
+
self._ws_server = None
|
|
326
|
+
|
|
327
|
+
# Shut the HTTP server down cleanly so the listening port and worker
|
|
328
|
+
# thread don't leak for the life of the process. serve_forever() runs on
|
|
329
|
+
# _http_thread, so shutdown() must be called from another thread (here).
|
|
330
|
+
if self._httpd is not None:
|
|
331
|
+
self._httpd.shutdown()
|
|
332
|
+
self._httpd.server_close()
|
|
333
|
+
self._httpd = None
|
|
334
|
+
|
|
335
|
+
if self._http_thread is not None:
|
|
336
|
+
self._http_thread.join(timeout=2.0)
|
|
337
|
+
self._http_thread = None
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
NodeType = typing.TypeVar("NodeType")
|
|
6
|
+
WeightModifierType = typing.Optional[typing.Callable[[NodeType, NodeType, int], float]]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WeightedGraph (typing.Generic[NodeType]):
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
A weighted directed graph with optional runtime weight adjustment.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__ (self) -> None:
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Initialize an empty weighted graph.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
self._edges: typing.Dict[NodeType, typing.Dict[NodeType, int]] = {}
|
|
22
|
+
self._labels: typing.Dict[typing.Tuple[NodeType, NodeType], str] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def add_transition (self, source: NodeType, target: NodeType, weight: int, label: typing.Optional[str] = None) -> None:
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
Add a weighted transition between two nodes.
|
|
29
|
+
|
|
30
|
+
Parameters:
|
|
31
|
+
source: Node the transition leaves from.
|
|
32
|
+
target: Node the transition arrives at.
|
|
33
|
+
weight: Positive transition weight. Re-adding an existing
|
|
34
|
+
transition accumulates, strengthening the edge.
|
|
35
|
+
label: Optional edge label naming the transition's musical
|
|
36
|
+
function (e.g. ``"cadence"``, ``"deceptive"``). A label
|
|
37
|
+
given on a re-add replaces the previous one; ``None``
|
|
38
|
+
leaves any existing label untouched.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
if weight <= 0:
|
|
42
|
+
raise ValueError("Weight must be positive")
|
|
43
|
+
|
|
44
|
+
if source not in self._edges:
|
|
45
|
+
self._edges[source] = {}
|
|
46
|
+
|
|
47
|
+
# If a transition already exists, accumulate to strengthen the edge.
|
|
48
|
+
if target in self._edges[source]:
|
|
49
|
+
self._edges[source][target] += weight
|
|
50
|
+
|
|
51
|
+
else:
|
|
52
|
+
self._edges[source][target] = weight
|
|
53
|
+
|
|
54
|
+
if label is not None:
|
|
55
|
+
self._labels[(source, target)] = label
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_label (self, source: NodeType, target: NodeType) -> typing.Optional[str]:
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
Return the label for a transition, or None if it has none.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
return self._labels.get((source, target))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def transitions_with_label (self, source: NodeType, label: str) -> typing.List[typing.Tuple[NodeType, int]]:
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
Return the outgoing transitions from *source* that carry *label*.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
(target, weight)
|
|
75
|
+
for target, weight in self.get_transitions(source)
|
|
76
|
+
if self._labels.get((source, target)) == label
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def nodes (self) -> typing.List[NodeType]:
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
Return every node that appears in the graph (as a source or a target),
|
|
84
|
+
in first-seen order.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
seen: typing.Dict[NodeType, None] = {}
|
|
88
|
+
|
|
89
|
+
for source, targets in self._edges.items():
|
|
90
|
+
seen.setdefault(source)
|
|
91
|
+
for target in targets:
|
|
92
|
+
seen.setdefault(target)
|
|
93
|
+
|
|
94
|
+
return list(seen)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_transitions (self, source: NodeType) -> typing.List[typing.Tuple[NodeType, int]]:
|
|
98
|
+
|
|
99
|
+
"""
|
|
100
|
+
Return weighted transitions for a source node.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
if source not in self._edges:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
return list(self._edges[source].items())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def choose_next (self, source: NodeType, rng: random.Random, weight_modifier: WeightModifierType = None) -> NodeType:
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
Choose the next node from a source using weighted randomness.
|
|
113
|
+
|
|
114
|
+
Returns *source* unchanged if the node has no outgoing transitions,
|
|
115
|
+
or if every outgoing transition has been suppressed by a weight
|
|
116
|
+
modifier that returned zero or a negative value.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
options = self.get_transitions(source)
|
|
120
|
+
|
|
121
|
+
if not options:
|
|
122
|
+
# Decision path: with no outgoing edges we remain on the current node.
|
|
123
|
+
return source
|
|
124
|
+
|
|
125
|
+
adjusted: typing.List[typing.Tuple[NodeType, float]] = []
|
|
126
|
+
total_weight = 0.0
|
|
127
|
+
|
|
128
|
+
for target, weight in options:
|
|
129
|
+
|
|
130
|
+
if weight_modifier is None:
|
|
131
|
+
modifier = 1.0
|
|
132
|
+
|
|
133
|
+
else:
|
|
134
|
+
modifier = float(weight_modifier(source, target, weight))
|
|
135
|
+
|
|
136
|
+
if modifier <= 0:
|
|
137
|
+
# Decision path: non-positive modifiers suppress this transition entirely.
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
adjusted_weight = float(weight) * modifier
|
|
141
|
+
adjusted.append((target, adjusted_weight))
|
|
142
|
+
total_weight += adjusted_weight
|
|
143
|
+
|
|
144
|
+
if total_weight <= 0:
|
|
145
|
+
# Decision path: if every transition is suppressed, stay on the current node.
|
|
146
|
+
return source
|
|
147
|
+
|
|
148
|
+
roll = rng.uniform(0, total_weight)
|
|
149
|
+
accum = 0.0
|
|
150
|
+
|
|
151
|
+
for target, adj_weight in adjusted:
|
|
152
|
+
accum += adj_weight
|
|
153
|
+
if roll <= accum:
|
|
154
|
+
return target
|
|
155
|
+
|
|
156
|
+
return adjusted[-1][0]
|