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.
Files changed (78) hide show
  1. subsequence/__init__.py +231 -0
  2. subsequence/__main__.py +24 -0
  3. subsequence/assets/web/index.html +345 -0
  4. subsequence/cadences.py +113 -0
  5. subsequence/chord_graphs/__init__.py +100 -0
  6. subsequence/chord_graphs/aeolian_minor.py +158 -0
  7. subsequence/chord_graphs/chromatic_mediant.py +113 -0
  8. subsequence/chord_graphs/diminished.py +97 -0
  9. subsequence/chord_graphs/dorian_minor.py +127 -0
  10. subsequence/chord_graphs/functional_major.py +102 -0
  11. subsequence/chord_graphs/hooktheory_major.py +88 -0
  12. subsequence/chord_graphs/lydian_major.py +130 -0
  13. subsequence/chord_graphs/mixolydian.py +98 -0
  14. subsequence/chord_graphs/phrygian_minor.py +76 -0
  15. subsequence/chord_graphs/suspended.py +109 -0
  16. subsequence/chord_graphs/turnaround_global.py +157 -0
  17. subsequence/chord_graphs/whole_tone.py +77 -0
  18. subsequence/chords.py +419 -0
  19. subsequence/composition.py +6099 -0
  20. subsequence/conductor.py +238 -0
  21. subsequence/constants/__init__.py +24 -0
  22. subsequence/constants/durations.py +37 -0
  23. subsequence/constants/instruments/__init__.py +13 -0
  24. subsequence/constants/instruments/gm_cc.py +46 -0
  25. subsequence/constants/instruments/gm_drums.py +53 -0
  26. subsequence/constants/instruments/gm_instruments.py +32 -0
  27. subsequence/constants/instruments/roland_tr8s.py +320 -0
  28. subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
  29. subsequence/constants/midi_notes.py +29 -0
  30. subsequence/constants/pulses.py +17 -0
  31. subsequence/constants/velocity.py +22 -0
  32. subsequence/definitions.py +232 -0
  33. subsequence/display.py +617 -0
  34. subsequence/easing.py +347 -0
  35. subsequence/event_emitter.py +109 -0
  36. subsequence/form_state.py +665 -0
  37. subsequence/forms.py +257 -0
  38. subsequence/groove.py +323 -0
  39. subsequence/harmonic_rhythm.py +83 -0
  40. subsequence/harmonic_state.py +352 -0
  41. subsequence/harmony.py +197 -0
  42. subsequence/held_notes.py +91 -0
  43. subsequence/helpers/__init__.py +0 -0
  44. subsequence/helpers/network.py +58 -0
  45. subsequence/helpers/wing.py +430 -0
  46. subsequence/intervals.py +436 -0
  47. subsequence/keystroke.py +249 -0
  48. subsequence/link_clock.py +128 -0
  49. subsequence/live_client.py +187 -0
  50. subsequence/live_reloader.py +298 -0
  51. subsequence/live_server.py +161 -0
  52. subsequence/melodic_state.py +483 -0
  53. subsequence/midi.py +97 -0
  54. subsequence/midi_utils.py +329 -0
  55. subsequence/mini_notation.py +164 -0
  56. subsequence/motifs.py +2356 -0
  57. subsequence/osc.py +194 -0
  58. subsequence/pattern.py +363 -0
  59. subsequence/pattern_algorithmic.py +2010 -0
  60. subsequence/pattern_builder.py +2589 -0
  61. subsequence/pattern_midi.py +1208 -0
  62. subsequence/progressions.py +1913 -0
  63. subsequence/py.typed +0 -0
  64. subsequence/roles.py +63 -0
  65. subsequence/sequence_utils.py +3123 -0
  66. subsequence/sequencer.py +2086 -0
  67. subsequence/tuning.py +453 -0
  68. subsequence/voicings.py +151 -0
  69. subsequence/web_ui.py +337 -0
  70. subsequence/weighted_graph.py +156 -0
  71. subsequence-0.6.4.dist-info/METADATA +208 -0
  72. subsequence-0.6.4.dist-info/RECORD +78 -0
  73. subsequence-0.6.4.dist-info/WHEEL +5 -0
  74. subsequence-0.6.4.dist-info/entry_points.txt +2 -0
  75. subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
  76. subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
  77. subsequence-0.6.4.dist-info/scm_version.json +8 -0
  78. 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]