pachet commited on
Commit
70e4406
·
0 Parent(s):

Initialize Continuator Web Pilot

Browse files
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ __pycache__
4
+ *.pyc
5
+ *.pyo
6
+ *.pyd
7
+ .DS_Store
8
+ .venv
9
+ venv
10
+ node_modules
11
+ backend/data/*.sqlite3
12
+ backend/data/*.sqlite3-*
13
+
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .DS_Store
4
+ .venv/
5
+ venv/
6
+ node_modules/
7
+ backend/data/*.sqlite3
8
+ backend/data/*.sqlite3-*
9
+
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ HOME=/home/user \
7
+ PATH=/home/user/.local/bin:$PATH
8
+
9
+ RUN useradd -m -u 1000 user
10
+ USER user
11
+ WORKDIR $HOME/app
12
+
13
+ COPY --chown=user backend/requirements.txt backend/requirements.txt
14
+ RUN pip install --upgrade pip && pip install -r backend/requirements.txt
15
+
16
+ COPY --chown=user backend backend
17
+ COPY --chown=user frontend frontend
18
+
19
+ EXPOSE 7860
20
+
21
+ CMD ["python", "-m", "uvicorn", "app.main:app", "--app-dir", "backend", "--host", "0.0.0.0", "--port", "7860"]
22
+
README.md ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Continuator Web Pilot
3
+ emoji: 🎹
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Continuator Web Pilot
12
+
13
+ This repository is a browser-based front-end and API wrapper for the Continuator system.
14
+ It lets a user play a MIDI phrase in the browser, send that phrase to a Python Continuator engine, receive a generated continuation, and play it back immediately.
15
+
16
+ The current implementation is meant as an MVP for experimentation, demos, and architecture validation. It already supports:
17
+
18
+ - Browser-side real-time MIDI capture with the Web MIDI API.
19
+ - A `FastAPI` backend that wraps the Python Continuator engine.
20
+ - One isolated Continuator engine per live session.
21
+ - SQLite logging for captured and generated phrases.
22
+ - A compact UI with MIDI controls, phrase playback, and session activity views.
23
+ - A tabbed `History | Memory` activity card.
24
+ - Docker packaging suitable for local containers and Hugging Face Docker Spaces.
25
+
26
+ ## Goals
27
+
28
+ This project is trying to validate a web architecture for Continuator with a clear separation of responsibilities:
29
+
30
+ - The browser owns the real-time MIDI interaction.
31
+ - The Python backend owns phrase learning and generation.
32
+ - Session state is isolated so multiple users do not share musical memory by accident.
33
+ - Phrase history is stored separately from the live model state.
34
+
35
+ This is important because the original Continuator codebase is centered on local Python usage, while this project explores a true client-server version with a JavaScript front-end.
36
+
37
+ ## Current Scope
38
+
39
+ What is implemented today:
40
+
41
+ - Session creation and reset.
42
+ - Web MIDI input selection in the browser.
43
+ - Browser synth playback or hardware MIDI output playback.
44
+ - Phrase capture based on silence detection.
45
+ - Continuator settings exposed through a compact advanced drawer.
46
+ - Per-session `History` and `Memory` visualization.
47
+
48
+ What is intentionally not implemented yet:
49
+
50
+ - Persistent user accounts.
51
+ - Persistent per-user musical memory across sessions.
52
+ - Shared session storage across multiple backend instances.
53
+ - A production-grade multi-user database architecture.
54
+
55
+ ## Product And UX Overview
56
+
57
+ The UI is organized around a simple performance loop:
58
+
59
+ - `Session`: create a new isolated Continuator session or reset its memory.
60
+ - `MIDI I/O`: connect browser MIDI, choose an input port, and choose a playback output.
61
+ - `Phrase Flow`: monitor captured and generated note counts, send a phrase manually, replay the last continuation, or clear local buffers.
62
+ - `Session Activity`: switch between `History` and `Memory`.
63
+
64
+ The UX principle is to keep the performance flow visible at all times and hide lower-frequency controls behind progressive disclosure:
65
+
66
+ - High-frequency controls stay on the main surface.
67
+ - Advanced model controls live in the `Advanced Continuator Settings` drawer.
68
+ - Memory inspection stays inside a local tabbed card rather than taking over the page.
69
+
70
+ ## High-Level Architecture
71
+
72
+ ```mermaid
73
+ flowchart LR
74
+ A["Browser UI<br/>Web MIDI + piano rolls + playback"] --> B["FastAPI API"]
75
+ B --> C["SessionManager"]
76
+ C --> D["Per-session ContinuatorSessionEngine"]
77
+ D --> E["Vendored Continuator core"]
78
+ C --> F["SQLite phrase history"]
79
+ D --> G["Optional seed MIDI corpus"]
80
+ ```
81
+
82
+ At a high level:
83
+
84
+ - The browser captures raw MIDI events in real time.
85
+ - The browser segments those events into phrases.
86
+ - The backend converts phrase JSON into `mido.Message` objects.
87
+ - The session-specific engine learns the phrase and samples a continuation.
88
+ - The backend converts the result back to JSON.
89
+ - The browser renders and plays the continuation.
90
+
91
+ ## Runtime Pipeline
92
+
93
+ The phrase pipeline is:
94
+
95
+ 1. A user clicks `Connect MIDI` and chooses a browser MIDI input.
96
+ 2. The browser receives `note_on` and `note_off` events in real time.
97
+ 3. The front-end keeps a phrase open while notes are still active.
98
+ 4. A phrase closes when all notes are released and 1 second has passed since the final note event, which in practice means 1 second after the final `note_off`.
99
+ 5. The browser stores the captured phrase as JSON with timing deltas.
100
+ 6. The client sends that JSON to `POST /api/continue`.
101
+ 7. The backend reconstructs `mido.Message` objects and asks Continuator for a phrase representation.
102
+ 8. The backend follows the current Continuator strategy: learn the input phrase first, then generate.
103
+ 9. The backend returns both the input phrase and generated phrase as note-level and event-level JSON payloads.
104
+ 10. The browser updates the piano rolls, logs the interaction in `History`, refreshes `Memory`, and plays the continuation.
105
+
106
+ By default, the backend asks for a continuation with the same note count as the input phrase.
107
+
108
+ For robustness, the wrapper currently retries generation without the hard end constraint if the exact same-length plus exact-ending request has no solution. This helps some dense or chordal phrases without modifying the Continuator core itself.
109
+
110
+ ## Session Model
111
+
112
+ A session is the main runtime boundary in this application.
113
+
114
+ Each session contains:
115
+
116
+ - One in-memory Continuator engine.
117
+ - One current settings snapshot.
118
+ - Logged phrase history in SQLite.
119
+ - A live memory view derived from the engine state.
120
+
121
+ Important distinction:
122
+
123
+ - `History` is everything that was logged for the session.
124
+ - `Memory` is only what is currently active inside the Continuator engine.
125
+
126
+ Those are not always the same:
127
+
128
+ - If `forget old phrases` is enabled, some historical phrases may no longer be active in memory.
129
+ - If `transpose` is enabled, one learned phrase can appear as several active memory sequences.
130
+ - If the backend is restarted, the in-memory engine is rebuilt, while SQLite history remains on disk.
131
+
132
+ ## Memory Model
133
+
134
+ The `Memory` tab is a visualization of the active engine state for the current session.
135
+
136
+ It shows:
137
+
138
+ - Summary chips with the active sequence count and settings.
139
+ - A recency ribbon from oldest to newest.
140
+ - A list of active memory slots, newest first.
141
+
142
+ The memory view is based on the Continuator engine's current `input_sequences`, not on the SQLite history log.
143
+
144
+ If a seed MIDI file or folder is loaded, memory items are labeled as:
145
+
146
+ - `seed`: sequences learned from the seed corpus at session initialization.
147
+ - `live`: sequences learned from browser interaction during the session.
148
+
149
+ Current limitation:
150
+
151
+ - This is session memory, not yet user memory.
152
+ - There is no persistent musical profile that follows a user across sessions.
153
+
154
+ ## Continuator Settings Exposed In The UI
155
+
156
+ The advanced drawer currently exposes existing Continuator parameters already present in the original Gradio interface:
157
+
158
+ - `Learn input`
159
+ - `Transpose`
160
+ - `Forget old phrases`
161
+ - `Keep only N last inputs`
162
+ - `Decay mode`
163
+
164
+ Design choice:
165
+
166
+ - `Learn input` stays visible because it directly affects performance behavior.
167
+ - The other parameters live in the advanced drawer because they are lower-frequency controls.
168
+
169
+ ## Repository Layout
170
+
171
+ ```text
172
+ backend/
173
+ app/
174
+ config.py
175
+ continuator_adapter.py
176
+ main.py
177
+ schemas.py
178
+ session_manager.py
179
+ storage.py
180
+ data/
181
+ vendor/
182
+ frontend/
183
+ app.js
184
+ index.html
185
+ styles.css
186
+ Dockerfile
187
+ README.md
188
+ ```
189
+
190
+ Main responsibilities:
191
+
192
+ - `frontend/index.html`: page structure and UI regions.
193
+ - `frontend/app.js`: MIDI capture, phrase segmentation, API calls, playback, and client-side visualization.
194
+ - `frontend/styles.css`: layout and visual design.
195
+ - `backend/app/main.py`: FastAPI routes.
196
+ - `backend/app/session_manager.py`: session lifecycle, orchestration, and API-facing session logic.
197
+ - `backend/app/continuator_adapter.py`: bridge between web JSON payloads and the Continuator Python engine.
198
+ - `backend/app/storage.py`: SQLite session and phrase logging.
199
+ - `backend/vendor/`: vendored Continuator code used by the web wrapper.
200
+
201
+ `backend/vendor/` contains the minimal Continuator modules needed for the web path, copied from the existing local `continuator` checkout and kept under the original license in `backend/vendor/LICENSE.continuator`.
202
+
203
+ ## API Surface
204
+
205
+ Current API endpoints:
206
+
207
+ - `GET /health`: lightweight health check.
208
+ - `GET /api/config`: public app-level configuration.
209
+ - `POST /api/session`: create a new session and its isolated Continuator engine.
210
+ - `PATCH /api/sessions/{session_id}/settings`: update live session settings.
211
+ - `POST /api/continue`: send one phrase and request a continuation.
212
+ - `GET /api/sessions/{session_id}/history`: retrieve recent logged phrase history.
213
+ - `GET /api/sessions/{session_id}/memory`: retrieve the active memory snapshot for the live engine.
214
+ - `POST /api/sessions/{session_id}/reset`: clear the live engine memory while preserving session settings.
215
+
216
+ API payloads use JSON and expose both:
217
+
218
+ - event-level timing data for playback
219
+ - note-level timing data for visualization
220
+
221
+ ## Local Development
222
+
223
+ Create a virtual environment, install dependencies, and launch the backend:
224
+
225
+ ```bash
226
+ python3 -m venv .venv
227
+ source .venv/bin/activate
228
+ pip install -r backend/requirements.txt
229
+ python -m uvicorn app.main:app --app-dir backend --reload
230
+ ```
231
+
232
+ Open:
233
+
234
+ ```text
235
+ http://127.0.0.1:8000
236
+ ```
237
+
238
+ Recommended browser support:
239
+
240
+ - Chrome
241
+ - Edge
242
+
243
+ `localhost` is a valid secure context for Web MIDI, so HTTPS is not required for local development.
244
+
245
+ Typical local interaction:
246
+
247
+ 1. Click `Create Session`.
248
+ 2. Click `Connect MIDI`.
249
+ 3. Choose a MIDI input.
250
+ 4. Choose `Browser Synth` or a hardware output.
251
+ 5. Optionally open `Advanced Continuator Settings`.
252
+ 6. Play a phrase.
253
+ 7. Wait 1 second after the final note release.
254
+ 8. Let auto-send submit the phrase, or click `Send Phrase`.
255
+ 9. Inspect `History` or `Memory` in the `Session Activity` card.
256
+
257
+ When you change frontend code, a normal refresh is usually enough.
258
+ When you change backend models or routes, restarting the server and refreshing the page is the safest option.
259
+
260
+ ## Configuration
261
+
262
+ Environment variables currently supported:
263
+
264
+ - `CONTINUATOR_APP_NAME`: override the displayed app name.
265
+ - `CONTINUATOR_DB_PATH`: move the SQLite database to another location.
266
+ - `CONTINUATOR_SEED_MIDI_FILE`: preload each new session with one MIDI file.
267
+ - `CONTINUATOR_SEED_MIDI_FOLDER`: preload each new session with a folder of MIDI files.
268
+ - `CONTINUATOR_SOURCE_DIR`: advanced development option to import Continuator from an external checkout instead of the vendored copy.
269
+
270
+ Examples:
271
+
272
+ ```bash
273
+ export CONTINUATOR_SEED_MIDI_FILE=/absolute/path/to/example.mid
274
+ export CONTINUATOR_DB_PATH=/absolute/path/to/continuator.sqlite3
275
+ ```
276
+
277
+ ## Optional Seed Corpus
278
+
279
+ If you want each new session to start from an existing corpus instead of an empty Continuator memory, set one of the seed variables before launching the server:
280
+
281
+ ```bash
282
+ export CONTINUATOR_SEED_MIDI_FILE=/absolute/path/to/example.mid
283
+ export CONTINUATOR_SEED_MIDI_FOLDER=/absolute/path/to/midi/folder
284
+ ```
285
+
286
+ This changes the starting memory of each newly created session, but sessions remain isolated from one another.
287
+
288
+ ## Docker
289
+
290
+ Build and run locally:
291
+
292
+ ```bash
293
+ docker build -t continuator-web .
294
+ docker run --rm -p 7860:7860 continuator-web
295
+ ```
296
+
297
+ Why the Docker image is simple:
298
+
299
+ - The frontend is static and does not require a separate Node build.
300
+ - The backend serves both the API and static assets.
301
+ - The image exposes port `7860`, which matches common Hugging Face Docker Space conventions.
302
+
303
+ ## Deploying To Hugging Face Spaces
304
+
305
+ This repository is now prepared to be used as a Docker Space.
306
+
307
+ The YAML block at the top of this `README.md` declares:
308
+
309
+ - `sdk: docker`
310
+ - `app_port: 7860`
311
+
312
+ Typical deployment flow:
313
+
314
+ 1. Create a new Space on Hugging Face.
315
+ 2. Choose the `Docker` SDK.
316
+ 3. Push this repository to the Space repository.
317
+ 4. Let the Space build automatically from the included `Dockerfile`.
318
+
319
+ If you want phrase history to survive restarts, attach a storage volume or bucket and mount it at `/data` (or another path), then point the database there with an environment variable such as:
320
+
321
+ ```bash
322
+ CONTINUATOR_DB_PATH=/data/continuator.sqlite3
323
+ ```
324
+
325
+ If you want each new session to start from a corpus, you can also define:
326
+
327
+ ```bash
328
+ CONTINUATOR_SEED_MIDI_FILE=/data/seeds/example.mid
329
+ CONTINUATOR_SEED_MIDI_FOLDER=/data/seeds
330
+ ```
331
+
332
+ For a first public demo, the simplest deployment is:
333
+
334
+ - free CPU hardware
335
+ - no persistent storage
336
+ - no seed corpus
337
+
338
+ That keeps the setup minimal and is enough to validate browser MIDI capture, API round-trips, and continuation playback.
339
+
340
+ ## Hugging Face Spaces Notes
341
+
342
+ This repository is shaped for a Docker Space:
343
+
344
+ - one container
345
+ - static frontend
346
+ - Python API
347
+ - no extra frontend build pipeline
348
+
349
+ That makes it a good fit for a public demo or lightweight prototype.
350
+
351
+ Things to keep in mind:
352
+
353
+ - The current live session registry is in process memory, so scaling to multiple replicas would require shared session storage.
354
+ - SQLite is fine for a prototype, but PostgreSQL would be a better next step for heavier concurrent use.
355
+ - Persistent user memory is not implemented yet.
356
+ - Long-lived or heavier multi-user deployments will need explicit storage, authentication, and horizontal architecture decisions.
357
+
358
+ ## Local Mac Usage After Space Setup
359
+
360
+ Yes: adding Hugging Face Space metadata does not change the local development workflow.
361
+
362
+ You can still run the app exactly as before:
363
+
364
+ ```bash
365
+ python3 -m venv .venv
366
+ source .venv/bin/activate
367
+ pip install -r backend/requirements.txt
368
+ python -m uvicorn app.main:app --app-dir backend --reload
369
+ ```
370
+
371
+ The Hugging Face YAML block is only repository metadata for the Space platform. The local frontend, backend, and Docker workflows continue to work the same way on macOS.
372
+
373
+ ## Current Limitations
374
+
375
+ - Session engines are in memory only.
376
+ - Memory does not survive a backend restart unless re-created from a seed corpus.
377
+ - There is no user account layer yet.
378
+ - The `Memory` tab shows session memory, not cross-session user memory.
379
+ - Web MIDI support depends on browser support and user permission.
380
+ - The Continuator core is currently treated as a black box from the web wrapper side.
381
+
382
+ ## Roadmap Ideas
383
+
384
+ Natural next steps for this project:
385
+
386
+ - Persistent per-user memory across sessions.
387
+ - Authentication or signed session tokens.
388
+ - A shared session store for multi-instance deployments.
389
+ - PostgreSQL instead of SQLite when concurrent writes matter.
390
+ - Inline piano-roll thumbnails in the `Memory` list.
391
+ - Stronger observability for generation behavior and debugging.
392
+ - A richer corpus management workflow for seed material.
backend/app/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Continuator web backend package."""
2
+
backend/app/config.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import os
6
+
7
+
8
+ BACKEND_DIR = Path(__file__).resolve().parents[1]
9
+ PROJECT_DIR = BACKEND_DIR.parent
10
+ FRONTEND_DIR = PROJECT_DIR / "frontend"
11
+ DATA_DIR = BACKEND_DIR / "data"
12
+ VENDOR_DIR = BACKEND_DIR / "vendor"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Settings:
17
+ app_name: str
18
+ frontend_dir: Path
19
+ vendor_dir: Path
20
+ db_path: Path
21
+ seed_midi_file: Path | None
22
+ seed_midi_folder: Path | None
23
+
24
+
25
+ def _env_path(name: str) -> Path | None:
26
+ raw = os.getenv(name)
27
+ if not raw:
28
+ return None
29
+ return Path(raw).expanduser()
30
+
31
+
32
+ def load_settings() -> Settings:
33
+ return Settings(
34
+ app_name=os.getenv("CONTINUATOR_APP_NAME", "Continuator Web"),
35
+ frontend_dir=FRONTEND_DIR,
36
+ vendor_dir=VENDOR_DIR,
37
+ db_path=_env_path("CONTINUATOR_DB_PATH") or (DATA_DIR / "continuator.sqlite3"),
38
+ seed_midi_file=_env_path("CONTINUATOR_SEED_MIDI_FILE"),
39
+ seed_midi_folder=_env_path("CONTINUATOR_SEED_MIDI_FOLDER"),
40
+ )
41
+
backend/app/continuator_adapter.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import os
5
+ import sys
6
+ import threading
7
+
8
+ import mido
9
+
10
+ from .schemas import MidiEvent, PhraseNote, PhrasePayload, PlaybackMidiEvent
11
+
12
+
13
+ BACKEND_DIR = Path(__file__).resolve().parents[1]
14
+ VENDOR_DIR = BACKEND_DIR / "vendor"
15
+
16
+
17
+ def _bootstrap_continuator_imports() -> None:
18
+ candidate_paths: list[Path] = []
19
+ external_source = os.getenv("CONTINUATOR_SOURCE_DIR")
20
+ if external_source:
21
+ candidate_paths.append(Path(external_source).expanduser())
22
+ candidate_paths.append(VENDOR_DIR)
23
+
24
+ for path in candidate_paths:
25
+ if path.exists():
26
+ resolved = str(path)
27
+ if resolved not in sys.path:
28
+ sys.path.insert(0, resolved)
29
+ return
30
+
31
+ raise RuntimeError("Could not locate Continuator source files.")
32
+
33
+
34
+ _bootstrap_continuator_imports()
35
+
36
+ from ctor.continuator import Continuator2 # noqa: E402
37
+
38
+
39
+ class NoContinuationAvailable(RuntimeError):
40
+ """Raised when the engine cannot generate a continuation."""
41
+
42
+
43
+ def _round_float(value: float) -> float:
44
+ return round(float(value), 6)
45
+
46
+
47
+ def _normalize_notes(notes: list[object]) -> list[object]:
48
+ normalized = [note.copy() if hasattr(note, "copy") else note for note in notes]
49
+ if not normalized:
50
+ return normalized
51
+
52
+ min_start = min(float(note.start_time) for note in normalized)
53
+ if min_start < 0:
54
+ for note in normalized:
55
+ note.start_time = float(note.start_time) - min_start
56
+
57
+ normalized.sort(key=lambda note: (float(note.start_time), int(note.pitch), float(note.duration)))
58
+ return normalized
59
+
60
+
61
+ def _note_to_schema(note: object) -> PhraseNote:
62
+ start_seconds = _round_float(note.start_time / 2.0)
63
+ duration_seconds = _round_float(note.duration / 2.0)
64
+ end_seconds = _round_float(start_seconds + duration_seconds)
65
+ return PhraseNote(
66
+ pitch=int(note.pitch),
67
+ velocity=int(note.velocity),
68
+ start_seconds=start_seconds,
69
+ duration_seconds=duration_seconds,
70
+ end_seconds=end_seconds,
71
+ start_beats=_round_float(note.start_time),
72
+ duration_beats=_round_float(note.duration),
73
+ )
74
+
75
+
76
+ def _notes_to_events(notes: list[object]) -> list[PlaybackMidiEvent]:
77
+ timed_events: list[dict[str, float | int | str]] = []
78
+ for note in notes:
79
+ start_seconds = note.start_time / 2.0
80
+ end_seconds = (note.start_time + note.duration) / 2.0
81
+ timed_events.append(
82
+ {
83
+ "type": "note_on",
84
+ "note": int(note.pitch),
85
+ "velocity": int(note.velocity),
86
+ "channel": 0,
87
+ "time_seconds": start_seconds,
88
+ }
89
+ )
90
+ timed_events.append(
91
+ {
92
+ "type": "note_off",
93
+ "note": int(note.pitch),
94
+ "velocity": 0,
95
+ "channel": 0,
96
+ "time_seconds": end_seconds,
97
+ }
98
+ )
99
+
100
+ timed_events.sort(
101
+ key=lambda item: (
102
+ float(item["time_seconds"]),
103
+ 0 if item["type"] == "note_off" else 1,
104
+ int(item["note"]),
105
+ )
106
+ )
107
+
108
+ events: list[PlaybackMidiEvent] = []
109
+ current_time = 0.0
110
+ for item in timed_events:
111
+ absolute_time = _round_float(float(item["time_seconds"]))
112
+ delta = _round_float(max(0.0, absolute_time - current_time))
113
+ current_time = absolute_time
114
+ events.append(
115
+ PlaybackMidiEvent(
116
+ type=str(item["type"]),
117
+ note=int(item["note"]),
118
+ velocity=int(item["velocity"]),
119
+ channel=int(item["channel"]),
120
+ delta_seconds=delta,
121
+ time_seconds=absolute_time,
122
+ )
123
+ )
124
+
125
+ return events
126
+
127
+
128
+ def _build_phrase_payload(notes: list[object]) -> PhrasePayload:
129
+ normalized_notes = _normalize_notes(notes)
130
+ note_payload = [_note_to_schema(note) for note in normalized_notes]
131
+ events = _notes_to_events(normalized_notes)
132
+ duration_seconds = max((note.end_seconds for note in note_payload), default=0.0)
133
+
134
+ return PhrasePayload(
135
+ event_count=len(events),
136
+ note_count=len(note_payload),
137
+ duration_seconds=_round_float(duration_seconds),
138
+ events=events,
139
+ notes=note_payload,
140
+ )
141
+
142
+
143
+ def _event_to_mido_message(event: MidiEvent) -> mido.Message:
144
+ velocity = event.velocity if event.type == "note_on" else 0
145
+ return mido.Message(
146
+ event.type,
147
+ note=event.note,
148
+ velocity=velocity,
149
+ channel=event.channel,
150
+ time=float(event.delta_seconds),
151
+ )
152
+
153
+
154
+ class ContinuatorSessionEngine:
155
+ def __init__(
156
+ self,
157
+ learn_input: bool = True,
158
+ transposition: bool = False,
159
+ forget_past: bool = False,
160
+ keep_last_inputs: int = 20,
161
+ decay_mode: str = "full",
162
+ seed_midi_file: Path | None = None,
163
+ seed_midi_folder: Path | None = None,
164
+ ) -> None:
165
+ self._default_learn_input = learn_input
166
+ self._transposition = transposition
167
+ self._forget_past = forget_past
168
+ self._keep_last_inputs = keep_last_inputs
169
+ self._decay_mode = decay_mode
170
+ self._seed_midi_file = seed_midi_file
171
+ self._seed_midi_folder = seed_midi_folder
172
+ self._seed_sequence_count = 0
173
+ self._lock = threading.RLock()
174
+ self._continuator = self._create_engine()
175
+
176
+ def _create_engine(self) -> Continuator2:
177
+ midi_file = str(self._seed_midi_file) if self._seed_midi_file else None
178
+ engine = Continuator2(midi_file=midi_file, transposition=self._transposition)
179
+ if self._seed_midi_folder:
180
+ engine.learn_folder(str(self._seed_midi_folder), transpose=self._transposition)
181
+ engine.set_learn_input(self._default_learn_input)
182
+ engine.set_transpose(self._transposition)
183
+ engine.set_forget(self._forget_past)
184
+ engine.set_keep_last(self._keep_last_inputs)
185
+ engine.set_decay_mode(self._decay_mode)
186
+ self._seed_sequence_count = len(getattr(engine.vom, "input_sequences", []))
187
+ return engine
188
+
189
+ def apply_settings(
190
+ self,
191
+ *,
192
+ learn_input: bool | None = None,
193
+ transposition: bool | None = None,
194
+ forget_past: bool | None = None,
195
+ keep_last_inputs: int | None = None,
196
+ decay_mode: str | None = None,
197
+ ) -> None:
198
+ with self._lock:
199
+ if learn_input is not None:
200
+ self._default_learn_input = learn_input
201
+ self._continuator.set_learn_input(learn_input)
202
+ if transposition is not None:
203
+ self._transposition = transposition
204
+ self._continuator.set_transpose(transposition)
205
+ if forget_past is not None:
206
+ self._forget_past = forget_past
207
+ self._continuator.set_forget(forget_past)
208
+ if keep_last_inputs is not None:
209
+ self._keep_last_inputs = keep_last_inputs
210
+ self._continuator.set_keep_last(keep_last_inputs)
211
+ if decay_mode is not None:
212
+ self._decay_mode = decay_mode
213
+ self._continuator.set_decay_mode(decay_mode)
214
+
215
+ def reset(self) -> None:
216
+ with self._lock:
217
+ self._continuator = self._create_engine()
218
+
219
+ def get_memory_snapshot(self) -> tuple[list[PhrasePayload], int]:
220
+ with self._lock:
221
+ sequences = list(getattr(self._continuator.vom, "input_sequences", []))
222
+ payloads = [_build_phrase_payload(sequence) for sequence in sequences]
223
+ seed_count = min(self._seed_sequence_count, len(payloads))
224
+ return payloads, seed_count
225
+
226
+ def continue_phrase(
227
+ self,
228
+ phrase_events: list[MidiEvent],
229
+ learn_input: bool | None = None,
230
+ continuation_note_count: int | None = None,
231
+ ) -> tuple[PhrasePayload, PhrasePayload, str | None]:
232
+ with self._lock:
233
+ messages = [_event_to_mido_message(event) for event in phrase_events]
234
+ input_phrase = self._continuator.get_phrase_from_mido(messages)
235
+ if not input_phrase:
236
+ raise NoContinuationAvailable("The incoming phrase did not contain any complete notes.")
237
+
238
+ should_learn = self._default_learn_input if learn_input is None else learn_input
239
+ input_payload = _build_phrase_payload(input_phrase)
240
+ target_note_count = continuation_note_count or len(input_phrase)
241
+
242
+ if should_learn:
243
+ self._continuator.learn_phrase(input_phrase, self._continuator.transpose)
244
+
245
+ status_message = None
246
+ constraints = {target_note_count: self._continuator.get_end_vp()}
247
+ generated_sequence = self._continuator.sample_sequence(
248
+ prefix=input_phrase,
249
+ length=target_note_count + 1,
250
+ constraints=constraints,
251
+ )
252
+ if generated_sequence is None:
253
+ generated_sequence = self._continuator.sample_sequence(
254
+ prefix=input_phrase,
255
+ length=target_note_count,
256
+ constraints={},
257
+ )
258
+ status_message = (
259
+ "Used a same-length continuation without the hard end constraint "
260
+ "because the exact-ending version had no solution."
261
+ )
262
+
263
+ if generated_sequence is None:
264
+ raise NoContinuationAvailable("The Continuator could not find a valid continuation.")
265
+
266
+ rendered_vp_sequence = generated_sequence
267
+ if rendered_vp_sequence and rendered_vp_sequence[-1] == self._continuator.get_end_vp():
268
+ rendered_vp_sequence = rendered_vp_sequence[:-1]
269
+
270
+ rendered_sequence = self._continuator.realize_vp_sequence(rendered_vp_sequence)
271
+ return input_payload, _build_phrase_payload(rendered_sequence), status_message
backend/app/main.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI, HTTPException, Query
4
+ from fastapi.responses import FileResponse
5
+ from fastapi.staticfiles import StaticFiles
6
+
7
+ from .config import load_settings
8
+ from .schemas import (
9
+ ContinueRequest,
10
+ ContinueResponse,
11
+ CreateSessionRequest,
12
+ CreateSessionResponse,
13
+ PublicConfigResponse,
14
+ ResetSessionResponse,
15
+ SessionHistoryResponse,
16
+ SessionMemoryResponse,
17
+ UpdateSessionSettingsRequest,
18
+ UpdateSessionSettingsResponse,
19
+ )
20
+ from .session_manager import NoContinuationAvailable, SessionManager, UnknownSessionError
21
+ from .storage import PhraseStorage
22
+
23
+
24
+ settings = load_settings()
25
+ storage = PhraseStorage(settings.db_path)
26
+ session_manager = SessionManager(
27
+ storage=storage,
28
+ seed_midi_file=settings.seed_midi_file,
29
+ seed_midi_folder=settings.seed_midi_folder,
30
+ )
31
+
32
+
33
+ app = FastAPI(
34
+ title=settings.app_name,
35
+ version="0.1.0",
36
+ description=(
37
+ "A lightweight web wrapper around François Pachet's Continuator, with "
38
+ "browser-side MIDI capture, session-isolated engines, and SQLite logging."
39
+ ),
40
+ )
41
+
42
+ app.mount("/assets", StaticFiles(directory=settings.frontend_dir), name="assets")
43
+
44
+
45
+ @app.get("/", include_in_schema=False)
46
+ def read_index() -> FileResponse:
47
+ return FileResponse(settings.frontend_dir / "index.html")
48
+
49
+
50
+ @app.get("/health", tags=["system"])
51
+ def healthcheck() -> dict[str, object]:
52
+ return {
53
+ "ok": True,
54
+ "app_name": settings.app_name,
55
+ "seeded": session_manager.seeded,
56
+ }
57
+
58
+
59
+ @app.get("/api/config", response_model=PublicConfigResponse, tags=["system"])
60
+ def public_config() -> PublicConfigResponse:
61
+ return PublicConfigResponse(
62
+ app_name=settings.app_name,
63
+ seeded=session_manager.seeded,
64
+ )
65
+
66
+
67
+ @app.post("/api/session", response_model=CreateSessionResponse, tags=["session"])
68
+ def create_session(payload: CreateSessionRequest) -> CreateSessionResponse:
69
+ return session_manager.create_session(payload)
70
+
71
+
72
+ @app.patch(
73
+ "/api/sessions/{session_id}/settings",
74
+ response_model=UpdateSessionSettingsResponse,
75
+ tags=["session"],
76
+ )
77
+ def update_session_settings(
78
+ session_id: str,
79
+ payload: UpdateSessionSettingsRequest,
80
+ ) -> UpdateSessionSettingsResponse:
81
+ try:
82
+ return session_manager.update_session_settings(session_id, payload)
83
+ except UnknownSessionError as error:
84
+ raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
85
+
86
+
87
+ @app.post("/api/continue", response_model=ContinueResponse, tags=["continuator"])
88
+ def continue_phrase(payload: ContinueRequest) -> ContinueResponse:
89
+ try:
90
+ return session_manager.continue_phrase(payload)
91
+ except UnknownSessionError as error:
92
+ raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
93
+ except NoContinuationAvailable as error:
94
+ raise HTTPException(status_code=409, detail=str(error)) from error
95
+
96
+
97
+ @app.get(
98
+ "/api/sessions/{session_id}/history",
99
+ response_model=SessionHistoryResponse,
100
+ tags=["session"],
101
+ )
102
+ def session_history(
103
+ session_id: str,
104
+ limit: int = Query(default=12, ge=1, le=100),
105
+ ) -> SessionHistoryResponse:
106
+ try:
107
+ return session_manager.get_history(session_id, limit=limit)
108
+ except UnknownSessionError as error:
109
+ raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
110
+
111
+
112
+ @app.get(
113
+ "/api/sessions/{session_id}/memory",
114
+ response_model=SessionMemoryResponse,
115
+ tags=["session"],
116
+ )
117
+ def session_memory(session_id: str) -> SessionMemoryResponse:
118
+ try:
119
+ return session_manager.get_memory(session_id)
120
+ except UnknownSessionError as error:
121
+ raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
122
+
123
+
124
+ @app.post(
125
+ "/api/sessions/{session_id}/reset",
126
+ response_model=ResetSessionResponse,
127
+ tags=["session"],
128
+ )
129
+ def reset_session(session_id: str) -> ResetSessionResponse:
130
+ try:
131
+ return session_manager.reset_session(session_id)
132
+ except UnknownSessionError as error:
133
+ raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
backend/app/schemas.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ DecayMode = Literal["full", "late", "middle", "early"]
9
+ MemoryPhraseSource = Literal["seed", "live"]
10
+
11
+
12
+ class MidiEvent(BaseModel):
13
+ type: Literal["note_on", "note_off"]
14
+ note: int = Field(ge=0, le=127)
15
+ velocity: int = Field(ge=0, le=127)
16
+ channel: int = Field(default=0, ge=0, le=15)
17
+ delta_seconds: float = Field(ge=0.0)
18
+
19
+
20
+ class PlaybackMidiEvent(MidiEvent):
21
+ time_seconds: float = Field(ge=0.0)
22
+
23
+
24
+ class PhraseNote(BaseModel):
25
+ pitch: int = Field(ge=0, le=127)
26
+ velocity: int = Field(ge=0, le=127)
27
+ start_seconds: float = Field(ge=0.0)
28
+ duration_seconds: float = Field(ge=0.0)
29
+ end_seconds: float = Field(ge=0.0)
30
+ start_beats: float = Field(ge=0.0)
31
+ duration_beats: float = Field(ge=0.0)
32
+
33
+
34
+ class PhrasePayload(BaseModel):
35
+ event_count: int = Field(ge=0)
36
+ note_count: int = Field(ge=0)
37
+ duration_seconds: float = Field(ge=0.0)
38
+ events: list[PlaybackMidiEvent]
39
+ notes: list[PhraseNote]
40
+
41
+
42
+ class CreateSessionRequest(BaseModel):
43
+ learn_input: bool = True
44
+ transposition: bool = False
45
+ forget_past: bool = False
46
+ keep_last_inputs: int = Field(default=20, ge=1, le=500)
47
+ decay_mode: DecayMode = "full"
48
+
49
+
50
+ class SessionConfiguration(BaseModel):
51
+ learn_input: bool
52
+ transposition: bool
53
+ forget_past: bool
54
+ keep_last_inputs: int = Field(ge=1, le=500)
55
+ decay_mode: DecayMode
56
+ seeded: bool
57
+
58
+
59
+ class CreateSessionResponse(BaseModel):
60
+ session_id: str
61
+ created_at: str
62
+ configuration: SessionConfiguration
63
+
64
+
65
+ class ContinueRequest(BaseModel):
66
+ session_id: str = Field(min_length=1)
67
+ phrase: list[MidiEvent] = Field(min_length=1)
68
+ learn_input: bool | None = None
69
+ continuation_note_count: int | None = Field(default=None, ge=1)
70
+
71
+
72
+ class ContinueResponse(BaseModel):
73
+ session_id: str
74
+ request_id: str
75
+ created_at: str
76
+ input_phrase: PhrasePayload
77
+ generated_phrase: PhrasePayload
78
+ status_message: str | None = None
79
+
80
+
81
+ class HistoryItem(BaseModel):
82
+ id: str
83
+ request_id: str
84
+ kind: Literal["input", "generated"]
85
+ created_at: str
86
+ event_count: int = Field(ge=0)
87
+ note_count: int = Field(ge=0)
88
+ duration_seconds: float = Field(ge=0.0)
89
+ payload: PhrasePayload
90
+
91
+
92
+ class SessionHistoryResponse(BaseModel):
93
+ session_id: str
94
+ items: list[HistoryItem]
95
+
96
+
97
+ class MemoryPhraseItem(BaseModel):
98
+ slot: int = Field(ge=1)
99
+ source: MemoryPhraseSource
100
+ event_count: int = Field(ge=0)
101
+ note_count: int = Field(ge=0)
102
+ duration_seconds: float = Field(ge=0.0)
103
+ payload: PhrasePayload
104
+
105
+
106
+ class SessionMemorySummary(BaseModel):
107
+ active_phrase_count: int = Field(ge=0)
108
+ seeded_phrase_count: int = Field(ge=0)
109
+ live_phrase_count: int = Field(ge=0)
110
+
111
+
112
+ class SessionMemoryResponse(BaseModel):
113
+ session_id: str
114
+ configuration: SessionConfiguration
115
+ summary: SessionMemorySummary
116
+ items: list[MemoryPhraseItem]
117
+
118
+
119
+ class ResetSessionResponse(BaseModel):
120
+ session_id: str
121
+ reset_at: str
122
+ configuration: SessionConfiguration
123
+
124
+
125
+ class UpdateSessionSettingsRequest(BaseModel):
126
+ learn_input: bool | None = None
127
+ transposition: bool | None = None
128
+ forget_past: bool | None = None
129
+ keep_last_inputs: int | None = Field(default=None, ge=1, le=500)
130
+ decay_mode: DecayMode | None = None
131
+
132
+
133
+ class UpdateSessionSettingsResponse(BaseModel):
134
+ session_id: str
135
+ updated_at: str
136
+ configuration: SessionConfiguration
137
+
138
+
139
+ class PublicConfigResponse(BaseModel):
140
+ app_name: str
141
+ seeded: bool
backend/app/session_manager.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+ import threading
7
+ import uuid
8
+
9
+ from .continuator_adapter import ContinuatorSessionEngine, NoContinuationAvailable
10
+ from .schemas import (
11
+ ContinueRequest,
12
+ ContinueResponse,
13
+ CreateSessionRequest,
14
+ CreateSessionResponse,
15
+ HistoryItem,
16
+ MemoryPhraseItem,
17
+ ResetSessionResponse,
18
+ SessionConfiguration,
19
+ SessionHistoryResponse,
20
+ SessionMemoryResponse,
21
+ SessionMemorySummary,
22
+ UpdateSessionSettingsRequest,
23
+ UpdateSessionSettingsResponse,
24
+ )
25
+ from .storage import PhraseStorage
26
+
27
+
28
+ class UnknownSessionError(KeyError):
29
+ """Raised when a session identifier is unknown."""
30
+
31
+
32
+ def utc_now_iso() -> str:
33
+ return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
34
+
35
+
36
+ @dataclass
37
+ class SessionState:
38
+ session_id: str
39
+ created_at: str
40
+ last_seen_at: str
41
+ configuration: SessionConfiguration
42
+ engine: ContinuatorSessionEngine
43
+
44
+
45
+ class SessionManager:
46
+ def __init__(
47
+ self,
48
+ storage: PhraseStorage,
49
+ seed_midi_file: Path | None = None,
50
+ seed_midi_folder: Path | None = None,
51
+ ) -> None:
52
+ self.storage = storage
53
+ self.seed_midi_file = seed_midi_file
54
+ self.seed_midi_folder = seed_midi_folder
55
+ self._sessions: dict[str, SessionState] = {}
56
+ self._lock = threading.RLock()
57
+
58
+ @property
59
+ def seeded(self) -> bool:
60
+ return bool(self.seed_midi_file or self.seed_midi_folder)
61
+
62
+ def create_session(self, request: CreateSessionRequest) -> CreateSessionResponse:
63
+ created_at = utc_now_iso()
64
+ session_id = uuid.uuid4().hex
65
+ configuration = SessionConfiguration(
66
+ learn_input=request.learn_input,
67
+ transposition=request.transposition,
68
+ forget_past=request.forget_past,
69
+ keep_last_inputs=request.keep_last_inputs,
70
+ decay_mode=request.decay_mode,
71
+ seeded=self.seeded,
72
+ )
73
+ engine = ContinuatorSessionEngine(
74
+ learn_input=request.learn_input,
75
+ transposition=request.transposition,
76
+ forget_past=request.forget_past,
77
+ keep_last_inputs=request.keep_last_inputs,
78
+ decay_mode=request.decay_mode,
79
+ seed_midi_file=self.seed_midi_file,
80
+ seed_midi_folder=self.seed_midi_folder,
81
+ )
82
+ state = SessionState(
83
+ session_id=session_id,
84
+ created_at=created_at,
85
+ last_seen_at=created_at,
86
+ configuration=configuration,
87
+ engine=engine,
88
+ )
89
+
90
+ with self._lock:
91
+ self._sessions[session_id] = state
92
+
93
+ self.storage.create_session(
94
+ session_id=session_id,
95
+ created_at=created_at,
96
+ metadata=configuration.model_dump(),
97
+ )
98
+ return CreateSessionResponse(
99
+ session_id=session_id,
100
+ created_at=created_at,
101
+ configuration=configuration,
102
+ )
103
+
104
+ def _require_session(self, session_id: str) -> SessionState:
105
+ with self._lock:
106
+ session = self._sessions.get(session_id)
107
+ if session is None:
108
+ raise UnknownSessionError(session_id)
109
+ return session
110
+
111
+ def continue_phrase(self, request: ContinueRequest) -> ContinueResponse:
112
+ state = self._require_session(request.session_id)
113
+ created_at = utc_now_iso()
114
+ request_id = uuid.uuid4().hex
115
+ input_phrase, generated_phrase, status_message = state.engine.continue_phrase(
116
+ request.phrase,
117
+ learn_input=request.learn_input,
118
+ continuation_note_count=request.continuation_note_count,
119
+ )
120
+
121
+ state.last_seen_at = created_at
122
+ self.storage.touch_session(state.session_id, created_at)
123
+ self.storage.log_phrase(
124
+ phrase_id=uuid.uuid4().hex,
125
+ request_id=request_id,
126
+ session_id=state.session_id,
127
+ kind="input",
128
+ created_at=created_at,
129
+ payload=input_phrase.model_dump(mode="json"),
130
+ )
131
+ self.storage.log_phrase(
132
+ phrase_id=uuid.uuid4().hex,
133
+ request_id=request_id,
134
+ session_id=state.session_id,
135
+ kind="generated",
136
+ created_at=created_at,
137
+ payload=generated_phrase.model_dump(mode="json"),
138
+ )
139
+
140
+ return ContinueResponse(
141
+ session_id=state.session_id,
142
+ request_id=request_id,
143
+ created_at=created_at,
144
+ input_phrase=input_phrase,
145
+ generated_phrase=generated_phrase,
146
+ status_message=status_message,
147
+ )
148
+
149
+ def get_history(self, session_id: str, limit: int = 20) -> SessionHistoryResponse:
150
+ if not self.storage.session_exists(session_id):
151
+ raise UnknownSessionError(session_id)
152
+ items = [
153
+ HistoryItem.model_validate(item)
154
+ for item in self.storage.get_history(session_id, limit=limit)
155
+ ]
156
+ return SessionHistoryResponse(session_id=session_id, items=items)
157
+
158
+ def get_memory(self, session_id: str) -> SessionMemoryResponse:
159
+ state = self._require_session(session_id)
160
+ payloads, seeded_count = state.engine.get_memory_snapshot()
161
+ items = [
162
+ MemoryPhraseItem(
163
+ slot=index + 1,
164
+ source="seed" if index < seeded_count else "live",
165
+ event_count=payload.event_count,
166
+ note_count=payload.note_count,
167
+ duration_seconds=payload.duration_seconds,
168
+ payload=payload,
169
+ )
170
+ for index, payload in enumerate(payloads)
171
+ ]
172
+ summary = SessionMemorySummary(
173
+ active_phrase_count=len(items),
174
+ seeded_phrase_count=seeded_count,
175
+ live_phrase_count=max(0, len(items) - seeded_count),
176
+ )
177
+ return SessionMemoryResponse(
178
+ session_id=session_id,
179
+ configuration=state.configuration,
180
+ summary=summary,
181
+ items=items,
182
+ )
183
+
184
+ def reset_session(self, session_id: str) -> ResetSessionResponse:
185
+ state = self._require_session(session_id)
186
+ state.engine.reset()
187
+ state.last_seen_at = utc_now_iso()
188
+ self.storage.touch_session(session_id, state.last_seen_at)
189
+ return ResetSessionResponse(
190
+ session_id=session_id,
191
+ reset_at=state.last_seen_at,
192
+ configuration=state.configuration,
193
+ )
194
+
195
+ def update_session_settings(
196
+ self,
197
+ session_id: str,
198
+ request: UpdateSessionSettingsRequest,
199
+ ) -> UpdateSessionSettingsResponse:
200
+ state = self._require_session(session_id)
201
+ updated_at = utc_now_iso()
202
+
203
+ update_fields = request.model_dump(exclude_none=True)
204
+ if not update_fields:
205
+ return UpdateSessionSettingsResponse(
206
+ session_id=session_id,
207
+ updated_at=updated_at,
208
+ configuration=state.configuration,
209
+ )
210
+
211
+ state.engine.apply_settings(**update_fields)
212
+ state.configuration = state.configuration.model_copy(update=update_fields)
213
+ state.last_seen_at = updated_at
214
+ self.storage.update_session_metadata(
215
+ session_id=session_id,
216
+ metadata=state.configuration.model_dump(),
217
+ last_seen_at=updated_at,
218
+ )
219
+ return UpdateSessionSettingsResponse(
220
+ session_id=session_id,
221
+ updated_at=updated_at,
222
+ configuration=state.configuration,
223
+ )
224
+
225
+
226
+ __all__ = [
227
+ "NoContinuationAvailable",
228
+ "SessionManager",
229
+ "UnknownSessionError",
230
+ ]
backend/app/storage.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import json
5
+ import sqlite3
6
+ import threading
7
+
8
+
9
+ SCHEMA_SQL = """
10
+ PRAGMA journal_mode = WAL;
11
+ PRAGMA foreign_keys = ON;
12
+
13
+ CREATE TABLE IF NOT EXISTS sessions (
14
+ id TEXT PRIMARY KEY,
15
+ created_at TEXT NOT NULL,
16
+ last_seen_at TEXT NOT NULL,
17
+ metadata_json TEXT NOT NULL
18
+ );
19
+
20
+ CREATE TABLE IF NOT EXISTS phrases (
21
+ id TEXT PRIMARY KEY,
22
+ request_id TEXT NOT NULL,
23
+ session_id TEXT NOT NULL,
24
+ kind TEXT NOT NULL CHECK(kind IN ('input', 'generated')),
25
+ created_at TEXT NOT NULL,
26
+ event_count INTEGER NOT NULL,
27
+ note_count INTEGER NOT NULL,
28
+ duration_seconds REAL NOT NULL,
29
+ payload_json TEXT NOT NULL,
30
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
31
+ );
32
+
33
+ CREATE INDEX IF NOT EXISTS idx_phrases_session_created
34
+ ON phrases(session_id, created_at DESC);
35
+ """
36
+
37
+
38
+ class PhraseStorage:
39
+ def __init__(self, db_path: Path) -> None:
40
+ self.db_path = db_path
41
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
42
+ self._lock = threading.RLock()
43
+ self._initialize()
44
+
45
+ def _connect(self) -> sqlite3.Connection:
46
+ connection = sqlite3.connect(self.db_path, check_same_thread=False)
47
+ connection.row_factory = sqlite3.Row
48
+ return connection
49
+
50
+ def _initialize(self) -> None:
51
+ with self._lock, self._connect() as connection:
52
+ connection.executescript(SCHEMA_SQL)
53
+
54
+ def create_session(self, session_id: str, created_at: str, metadata: dict[str, object]) -> None:
55
+ payload = json.dumps(metadata, ensure_ascii=False)
56
+ with self._lock, self._connect() as connection:
57
+ connection.execute(
58
+ """
59
+ INSERT OR REPLACE INTO sessions (id, created_at, last_seen_at, metadata_json)
60
+ VALUES (?, ?, ?, ?)
61
+ """,
62
+ (session_id, created_at, created_at, payload),
63
+ )
64
+ connection.commit()
65
+
66
+ def touch_session(self, session_id: str, last_seen_at: str) -> None:
67
+ with self._lock, self._connect() as connection:
68
+ connection.execute(
69
+ "UPDATE sessions SET last_seen_at = ? WHERE id = ?",
70
+ (last_seen_at, session_id),
71
+ )
72
+ connection.commit()
73
+
74
+ def update_session_metadata(
75
+ self,
76
+ session_id: str,
77
+ metadata: dict[str, object],
78
+ last_seen_at: str | None = None,
79
+ ) -> None:
80
+ payload = json.dumps(metadata, ensure_ascii=False)
81
+ with self._lock, self._connect() as connection:
82
+ if last_seen_at is None:
83
+ connection.execute(
84
+ "UPDATE sessions SET metadata_json = ? WHERE id = ?",
85
+ (payload, session_id),
86
+ )
87
+ else:
88
+ connection.execute(
89
+ """
90
+ UPDATE sessions
91
+ SET metadata_json = ?, last_seen_at = ?
92
+ WHERE id = ?
93
+ """,
94
+ (payload, last_seen_at, session_id),
95
+ )
96
+ connection.commit()
97
+
98
+ def session_exists(self, session_id: str) -> bool:
99
+ with self._lock, self._connect() as connection:
100
+ row = connection.execute(
101
+ "SELECT 1 FROM sessions WHERE id = ? LIMIT 1",
102
+ (session_id,),
103
+ ).fetchone()
104
+ return row is not None
105
+
106
+ def log_phrase(
107
+ self,
108
+ phrase_id: str,
109
+ request_id: str,
110
+ session_id: str,
111
+ kind: str,
112
+ created_at: str,
113
+ payload: dict[str, object],
114
+ ) -> None:
115
+ serialized_payload = json.dumps(payload, ensure_ascii=False)
116
+ event_count = int(payload.get("event_count", 0))
117
+ note_count = int(payload.get("note_count", 0))
118
+ duration_seconds = float(payload.get("duration_seconds", 0.0))
119
+
120
+ with self._lock, self._connect() as connection:
121
+ connection.execute(
122
+ """
123
+ INSERT INTO phrases (
124
+ id,
125
+ request_id,
126
+ session_id,
127
+ kind,
128
+ created_at,
129
+ event_count,
130
+ note_count,
131
+ duration_seconds,
132
+ payload_json
133
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
134
+ """,
135
+ (
136
+ phrase_id,
137
+ request_id,
138
+ session_id,
139
+ kind,
140
+ created_at,
141
+ event_count,
142
+ note_count,
143
+ duration_seconds,
144
+ serialized_payload,
145
+ ),
146
+ )
147
+ connection.commit()
148
+
149
+ def get_history(self, session_id: str, limit: int = 20) -> list[dict[str, object]]:
150
+ with self._lock, self._connect() as connection:
151
+ rows = connection.execute(
152
+ """
153
+ SELECT
154
+ id,
155
+ request_id,
156
+ kind,
157
+ created_at,
158
+ event_count,
159
+ note_count,
160
+ duration_seconds,
161
+ payload_json
162
+ FROM phrases
163
+ WHERE session_id = ?
164
+ ORDER BY created_at DESC
165
+ LIMIT ?
166
+ """,
167
+ (session_id, limit),
168
+ ).fetchall()
169
+
170
+ return [
171
+ {
172
+ "id": row["id"],
173
+ "request_id": row["request_id"],
174
+ "kind": row["kind"],
175
+ "created_at": row["created_at"],
176
+ "event_count": row["event_count"],
177
+ "note_count": row["note_count"],
178
+ "duration_seconds": row["duration_seconds"],
179
+ "payload": json.loads(row["payload_json"]),
180
+ }
181
+ for row in rows
182
+ ]
backend/data/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
backend/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.115,<1
2
+ uvicorn[standard]>=0.30,<1
3
+ pydantic>=2.7,<3
4
+ numpy>=2.0,<3
5
+ mido>=1.3,<2
6
+
backend/vendor/LICENSE.continuator ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ynosound
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
backend/vendor/ctor/__init__.py ADDED
File without changes
backend/vendor/ctor/belief_propag.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from collections import namedtuple
3
+
4
+
5
+ class NoSolutionErrorInBP(Exception):
6
+ def __init__(self, message):
7
+ self.message = message
8
+
9
+
10
+ # code by Jessica Stringham:
11
+ # https://jessicastringham.net/2019/01/09/sum-product-message-passing/
12
+
13
+ LabeledArray = namedtuple(
14
+ "LabeledArray",
15
+ [
16
+ "array",
17
+ "axes_labels",
18
+ ],
19
+ )
20
+
21
+
22
+ def name_to_axis_mapping(labeled_array):
23
+ return {name: axis for axis, name in enumerate(labeled_array.axes_labels)}
24
+
25
+
26
+ def other_axes_from_labeled_axes(labeled_array, axis_label):
27
+ # returns the indexes of the axes that are not axis label
28
+ return tuple(
29
+ axis
30
+ for axis, name in enumerate(labeled_array.axes_labels)
31
+ if name != axis_label
32
+ )
33
+
34
+
35
+ def is_conditional_prob(labeled_array, var_name):
36
+ """
37
+ labeled_array (LabeledArray)
38
+ variable (str): name of variable, i.e. 'a' in p(a|b)
39
+ """
40
+ return np.all(
41
+ np.isclose(
42
+ np.sum(
43
+ labeled_array.array, axis=name_to_axis_mapping(labeled_array)[var_name]
44
+ ),
45
+ 1.0,
46
+ )
47
+ )
48
+
49
+
50
+ def is_joint_prob(labeled_array):
51
+ return np.all(np.isclose(np.sum(labeled_array.array), 1.0))
52
+
53
+
54
+ def tile_to_shape_along_axis(arr, target_shape, target_axis):
55
+ # get a list of all axes
56
+ raw_axes = list(range(len(target_shape)))
57
+ tile_dimensions = [target_shape[a] for a in raw_axes if a != target_axis]
58
+ if len(arr.shape) == 0:
59
+ # If given a scalar, also tile it in the target dimension (so it's a bunch of 1s)
60
+ tile_dimensions += [target_shape[target_axis]]
61
+ elif len(arr.shape) == 1:
62
+ # If given an array, it should be the same shape as the target axis
63
+ assert arr.shape[0] == target_shape[target_axis]
64
+ tile_dimensions += [1]
65
+ else:
66
+ raise NotImplementedError()
67
+ tiled = np.tile(arr, tile_dimensions)
68
+
69
+ # Tiling only adds prefix axes, so rotate this one back into place
70
+ shifted_axes = raw_axes[:target_axis] + [raw_axes[-1]] + raw_axes[target_axis:-1]
71
+ transposed = np.transpose(tiled, shifted_axes)
72
+
73
+ # Double-check this code tiled it to the correct shape
74
+ assert transposed.shape == target_shape
75
+ return transposed
76
+
77
+
78
+ def tile_to_other_dist_along_axis_name(tiling_labeled_array, target_array):
79
+ assert len(tiling_labeled_array.axes_labels) == 1
80
+ target_axis_label = tiling_labeled_array.axes_labels[0]
81
+
82
+ return LabeledArray(
83
+ tile_to_shape_along_axis(
84
+ tiling_labeled_array.array,
85
+ target_array.array.shape,
86
+ name_to_axis_mapping(target_array)[target_axis_label],
87
+ ),
88
+ axes_labels=target_array.axes_labels,
89
+ )
90
+
91
+
92
+ class Node(object):
93
+ def __init__(self, name):
94
+ self.name = name
95
+ self.neighbors = []
96
+
97
+ def __repr__(self):
98
+ return "{classname}({name}, [{neighbors}])".format(
99
+ classname=type(self).__name__,
100
+ name=self.name,
101
+ neighbors=", ".join([n.name for n in self.neighbors]),
102
+ )
103
+
104
+ def is_valid_neighbor(self, neighbor):
105
+ raise NotImplemented()
106
+
107
+ def add_neighbor(self, neighbor):
108
+ assert self.is_valid_neighbor(neighbor)
109
+ self.neighbors.append(neighbor)
110
+
111
+
112
+ class Variable(Node):
113
+ def is_valid_neighbor(self, factor):
114
+ return isinstance(factor, Factor) # Variables can only neighbor Factors
115
+
116
+
117
+ class Factor(Node):
118
+ def is_valid_neighbor(self, variable):
119
+ return isinstance(variable, Variable) # Factors can only neighbor Variables
120
+
121
+ def __init__(self, name):
122
+ super(Factor, self).__init__(name)
123
+ self.data = None
124
+
125
+
126
+ ParsedTerm = namedtuple(
127
+ "ParsedTerm",
128
+ [
129
+ "term",
130
+ "var_name",
131
+ "given",
132
+ ],
133
+ )
134
+
135
+
136
+ def _parse_term(term):
137
+ # Given a term like (a|b,c), returns a list of variables
138
+ # and conditioned-on variables
139
+ assert term[0] == "(" and term[-1] == ")"
140
+ term_variables = term[1:-1]
141
+
142
+ # Handle conditionals
143
+ if "|" in term_variables:
144
+ var, given = term_variables.split("|")
145
+ given = given.split(",")
146
+ else:
147
+ var = term_variables
148
+ given = []
149
+
150
+ return var, given
151
+
152
+
153
+ def _parse_model_string_into_terms(model_string):
154
+ return [
155
+ ParsedTerm("p" + term, *_parse_term(term))
156
+ for term in model_string.split("p")
157
+ if term
158
+ ]
159
+
160
+
161
+ def parse_model_into_variables_and_factors(model_string):
162
+ # Takes in a model_string such as p(h1)p(h2∣h1)p(v1∣h1)p(v2∣h2) and returns a
163
+ # dictionary of variable names to variables and a list of factors.
164
+
165
+ # Split model_string into ParsedTerms
166
+ parsed_terms = _parse_model_string_into_terms(model_string)
167
+
168
+ # First, extract all of the variables from the model_string (h1, h2, v1, v2).
169
+ # These each will be a new Variable that are referenced from Factors below.
170
+ variables = {}
171
+ for parsed_term in parsed_terms:
172
+ # if the variable name wasn't seen yet, add it to the variables dict
173
+ if parsed_term.var_name not in variables:
174
+ variables[parsed_term.var_name] = Variable(parsed_term.var_name)
175
+
176
+ # Now extract factors from the model. Each term (e.g. "p(v1|h1)") corresponds to
177
+ # a factor.
178
+ # Then find all variables in this term ("v1", "h1") and add the corresponding Variables
179
+ # as neighbors to the new Factor, and this Factor to the Variables' neighbors.
180
+ factors = []
181
+ for parsed_term in parsed_terms:
182
+ # This factor will be neighbors with all "variables" (left-hand side variables) and given variables
183
+ new_factor = Factor(parsed_term.term)
184
+ all_var_names = [parsed_term.var_name] + parsed_term.given
185
+ for var_name in all_var_names:
186
+ new_factor.add_neighbor(variables[var_name])
187
+ variables[var_name].add_neighbor(new_factor)
188
+ factors.append(new_factor)
189
+
190
+ return factors, variables
191
+
192
+
193
+ class PGM(object):
194
+ def __init__(self, factors, variables):
195
+ self._factors = factors
196
+ self._variables = variables
197
+
198
+ @classmethod
199
+ def from_string(cls, model_string):
200
+ factors, variables = parse_model_into_variables_and_factors(model_string)
201
+ return PGM(factors, variables)
202
+
203
+ def set_data(self, data):
204
+ # Keep track of variable dimensions to check for shape mistakes
205
+ var_dims = {}
206
+ for factor in self._factors:
207
+ factor_data = data[factor.name]
208
+
209
+ if set(factor_data.axes_labels) != set(v.name for v in factor.neighbors):
210
+ missing_axes = set(v.name for v in factor.neighbors) - set(
211
+ data[factor.name].axes_labels
212
+ )
213
+ raise ValueError(
214
+ "data[{}] is missing axes: {}".format(factor.name, missing_axes)
215
+ )
216
+
217
+ for var_name, dim in zip(factor_data.axes_labels, factor_data.array.shape):
218
+ if var_name not in var_dims:
219
+ var_dims[var_name] = dim
220
+
221
+ if var_dims[var_name] != dim:
222
+ raise ValueError(
223
+ "data[{}] axes is wrong size, {}. Expected {}".format(
224
+ factor.name, dim, var_dims[var_name]
225
+ )
226
+ )
227
+
228
+ factor.data = data[factor.name]
229
+
230
+ def variable_from_name(self, var_name):
231
+ return self._variables[var_name]
232
+
233
+ def factor_from_name(self, fac_name):
234
+ for f in self._factors:
235
+ if f.name == fac_name:
236
+ return f
237
+ print(f"factor not found: {fac_name}")
238
+ return None
239
+
240
+ def print_marginals(self):
241
+ for var in self._variables.values():
242
+ print(f"marginal: {var.name}: {Messages().marginal(var)}")
243
+
244
+ def set_value(self, var_name, value_idx):
245
+ factor = self.factor_from_name('p(' + var_name + ')')
246
+ data = np.zeros(len(factor.data.array))
247
+ data[value_idx] = 1
248
+ factor.data = LabeledArray(data, [var_name])
249
+
250
+
251
+ class Messages(object):
252
+ def __init__(self):
253
+ self.messages = {}
254
+
255
+ def _variable_to_factor_messages(self, variable, factor):
256
+ # print (f"_variable_to_factor_messages: {variable} to {factor}") # Take the product over all incoming factors into this variable except the variable
257
+ incoming_messages = [
258
+ self.factor_to_variable_message(neighbor_factor, variable)
259
+ for neighbor_factor in variable.neighbors
260
+ if neighbor_factor.name != factor.name
261
+ ]
262
+
263
+ # If there are no incoming messages, this is 1
264
+ return np.prod(incoming_messages, axis=0)
265
+
266
+ def _factor_to_variable_messages(self, factor, variable):
267
+ # print (f"_factor_to_variable_message: {factor} to {variable}")
268
+ # Compute the product
269
+ factor_dist = np.copy(factor.data.array)
270
+ for neighbor_variable in factor.neighbors:
271
+ if neighbor_variable.name == variable.name:
272
+ continue
273
+ incoming_message = self.variable_to_factor_messages(
274
+ neighbor_variable, factor
275
+ )
276
+ factor_dist *= tile_to_other_dist_along_axis_name(
277
+ LabeledArray(incoming_message, [neighbor_variable.name]), factor.data
278
+ ).array
279
+ # Sum over the axes that aren't `variable`
280
+ other_axes = other_axes_from_labeled_axes(factor.data, variable.name)
281
+ return np.squeeze(np.sum(factor_dist, axis=other_axes))
282
+
283
+ def marginal(self, variable):
284
+ # p(variable) is proportional to the product of incoming messages to variable.
285
+ unnorm_p = np.prod(
286
+ [
287
+ self.factor_to_variable_message(neighbor_factor, variable)
288
+ for neighbor_factor in variable.neighbors
289
+ ],
290
+ axis=0,
291
+ )
292
+ # At this point, we can normalize this distribution
293
+ somme = np.sum(unnorm_p)
294
+ if somme == 0:
295
+ raise NoSolutionErrorInBP("marginals are nan")
296
+ return unnorm_p / somme
297
+
298
+ def variable_to_factor_messages(self, variable, factor):
299
+ # print (f"variable_to_factor_messages: {variable} to {factor}")
300
+ message_name = (variable.name, factor.name)
301
+ if message_name not in self.messages:
302
+ self.messages[message_name] = self._variable_to_factor_messages(
303
+ variable, factor
304
+ )
305
+ return self.messages[message_name]
306
+
307
+ def factor_to_variable_message(self, factor, variable):
308
+ # print (f"factor_to_variable_message: {factor} to {variable}")
309
+ message_name = (factor.name, variable.name)
310
+ if message_name not in self.messages:
311
+ self.messages[message_name] = self._factor_to_variable_messages(
312
+ factor, variable
313
+ )
314
+ return self.messages[message_name]
315
+
316
+
317
+ def one_hot(size, index):
318
+ data = np.zeros(size, dtype=float)
319
+ data[index] = 1
320
+ return data
backend/vendor/ctor/continuator.py ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Copyright (c) 2025 Ynosound.
3
+ All rights reserved.
4
+
5
+ See LICENSE file in the project root for full license information.
6
+ """
7
+
8
+ import pathlib
9
+ import numpy as np
10
+ import mido
11
+ import random
12
+ import time
13
+ from difflib import SequenceMatcher
14
+ import os
15
+
16
+ from ctor.variable_order_markov import Variable_order_Markov
17
+ from midi_stuff.mini_muse import Note
18
+
19
+ """
20
+ - Split the music Continuator class from a generic Variable_Order_Markov, usable for any type of sequence (e.g. words).
21
+ - Implementation of Continuator is different from original, to enable experiments with belief propagation and skips.
22
+ - Representation of contexts of size 1 to K and their continuations with dictionaries. Trees/oracles are useless here.
23
+ - Contexts are tuples of viewpoints AND continuations are viewpoints (see get_viewpoint()) (Unlike in the original)
24
+ - Realizations are kept separately for each vp and reused during sampling. They are represented as addresses, i.e. tuple (index_of_melody, index_in_melody)
25
+ - Sampling attempts to avoid too long repetitions (a kind of max-order) by avoiding singletons when it can
26
+ - Sampling is performed both by belief propagation (1st order) and by variable-order and combined
27
+ - Realization of viewpoints is performed with dynamic programming, à la HMM
28
+ - Representation of polyphony is different from original Continuator. Clusters are not considered, only notes.
29
+ They have a "status" describing how they were played originally, which is preserved at sampling. This enables more creativity for chords.
30
+ - TODO: retrain periodically with computed viewpoints (bin quartiles for durations and velocity)
31
+ - TODO: audio synthesis with Dawdreamer
32
+ - TODO: add database storage of real time performances
33
+ - TODO: data augmentation with inversions, negative harmony, etc.
34
+ - TODO: rhythm transfer for data augmentation/control
35
+ - TODO: server with js client, or huggingface solution or github page with python2js
36
+ - TODO: use fine-tuning of transformers
37
+ """
38
+
39
+ class Continuator2:
40
+
41
+ def __init__(self, midi_file: object = None, kmax: int = 4, transposition: bool = False) -> None:
42
+ self.learn_input = True
43
+ # self.vom = Variable_order_Markov(None, self.get_viewpoint, kmax)
44
+ self.vom = Variable_order_Markov(
45
+ sequence_of_stuff=None,
46
+ vp_lambda=self.get_viewpoint, # identity viewpoint
47
+ kmax=kmax,
48
+ decay_fast_half_life=10, # forget half the influence every ~10 events
49
+ decay_slow_half_life=80, # for 'middle' band if you test it later
50
+ seed=0
51
+ )
52
+ # self.vom.set_period_mode("late") # 'late' uses recent (fast-decayed) counts
53
+
54
+ self.tempo_msgs = []
55
+ self.transpose = transposition
56
+ self.forget_past = False
57
+ self.keep_last_n_melodies = 20
58
+ # for generation from midifiles
59
+ self.generate_length = 10
60
+ if midi_file is not None:
61
+ self.learn_file(midi_file, transposition)
62
+
63
+ @staticmethod
64
+ def get_viewpoint(note):
65
+ nb_beats_per_bin = 1
66
+ vp = tuple([note.pitch, int(note.duration / nb_beats_per_bin), note.overlaps_left(), note.overlaps_right()])
67
+ # vp = tuple([note.pitch, (int)(note.duration / 10)])
68
+ return vp
69
+
70
+ def set_learn_input(self, value):
71
+ self.learn_input = value
72
+
73
+ def get_learn_input(self):
74
+ return self.learn_input
75
+
76
+ def set_forget(self, forget_past):
77
+ self.forget_past = forget_past
78
+
79
+ def set_keep_last(self, keep):
80
+ self.keep_last_n_melodies = keep
81
+
82
+ def set_transpose(self, trans):
83
+ self.transpose = trans
84
+
85
+ def set_decay_mode(self, choice):
86
+ self.vom.set_period_mode(choice)
87
+
88
+ def get_phrase_titles(self):
89
+ return [f"{i + 1} phrase with {len(phrase)} notes" for i, phrase in enumerate(self.vom.input_sequences)]
90
+
91
+ def get_phrase(self, index):
92
+ return self.vom.input_sequences[index]
93
+
94
+ def clear_memory(self):
95
+ self.vom.clear_memory()
96
+
97
+ def clear_first_n_phrases(self, n):
98
+ self.vom.clear_first_N_phrases(n)
99
+
100
+ def clear_last_phrase(self):
101
+ self.vom.clear_last_phrase()
102
+
103
+ def learn_file(self, midi_file, transposition):
104
+ notes_original = self.extract_notes(midi_file)
105
+ self.learn_phrase(notes_original, transposition)
106
+
107
+ def learn_folder(self, folder_path, transpose=False):
108
+ all_files = []
109
+ for root, _, files in os.walk(folder_path):
110
+ for fname in files:
111
+ if fname.lower().endswith((".mid", ".midi")):
112
+ full_path = os.path.join(root, fname)
113
+ all_files.append(full_path)
114
+ self.learn_file(full_path, transpose)
115
+ return all_files
116
+
117
+
118
+ def quantile_bins(self, values, N):
119
+ """
120
+ Compute bin edges that split the input values into N bins
121
+ with approximately equal number of points (quantiles).
122
+
123
+ Args:
124
+ values: list or array of numerical values (e.g., durations or velocities)
125
+ N: number of desired bins
126
+
127
+ Returns:
128
+ bin_edges: list of N+1 edges that define the bin intervals
129
+ """
130
+ values = np.array(values)
131
+ quantiles = np.linspace(0, 1, N + 1)
132
+ bin_edges = np.quantile(values, quantiles)
133
+ return bin_edges.tolist()
134
+
135
+
136
+ def get_all_input_durations(self):
137
+ all_durations = []
138
+ for note_seq in self.vom.input_sequences:
139
+ all_durations = all_durations + [n.duration for n in note_seq]
140
+ return all_durations
141
+
142
+ def compute_viewpoints(self, note_sequence):
143
+ all_durations = self.get_all_input_durations()
144
+ all_durations = all_durations + [n.duration for n in note_sequence]
145
+ all_durations.sort()
146
+ # print(self.vom.all_unique_viewpoints)
147
+ # print(self.quantile_bins(all_durations, 2))
148
+
149
+ def learn_phrase(self, note_sequence, transposition):
150
+ # should I forget some phrases ?
151
+ self.compute_viewpoints(note_sequence)
152
+ if len(note_sequence) == 0:
153
+ return
154
+ if self.forget_past and self.keep_last_n_melodies <= len(self.vom.input_sequences):
155
+ self.clear_first_n_phrases(1 + len(self.vom.input_sequences) - self.keep_last_n_melodies)
156
+ # all_pitches = [note.pitch for note in note_sequence]
157
+ # print(f"number of different pitches in train: {len(Counter(all_pitches))}")
158
+ # print(f"min pitch: {min(all_pitches)}, max pitch: {max(all_pitches)}")
159
+ # learns, possibly in 12 transpositions
160
+ trange = range(0, 1)
161
+ if transposition:
162
+ trange = range(-6, 6, 1)
163
+ for t in trange:
164
+ transposed = self.transpose_notes(note_sequence, t)
165
+ # learns one more sequence
166
+ self.vom.learn_sequence(transposed)
167
+
168
+
169
+ def learn_files(self, files, transposition=False):
170
+ # suppose at least one file has been learned already
171
+ for file in files:
172
+ self.learn_file(file, transposition)
173
+
174
+ # mido gives time in milliseconds from real input. Converts it into beast, assuming 120bpm
175
+ def learn_phrase_from_mido(self, phrase):
176
+ self.learn_phrase(self.get_phrase_from_mido(phrase), False)
177
+
178
+ def get_phrase_from_mido(self, phrase):
179
+ sequence = []
180
+ pending_notes = {}
181
+ # assign ABSOLUTE TIME to each message first, by cumulating all the deltas
182
+ # time here is in milliseconds
183
+ start_time = 0
184
+ for msg in phrase:
185
+ start_time = start_time + msg.time
186
+ msg.time = start_time
187
+ # joins note on and note off
188
+ for msg in phrase:
189
+ if msg.type == "note_on" and msg.velocity > 0:
190
+ pending_notes[msg.note] = msg
191
+ else:
192
+ if msg.type == "note_off" or (msg.type == 'note_on' and msg.velocity == 0):
193
+ if msg.note not in pending_notes:
194
+ print('⚠️ problem: note off does not match previous note on: ' + str(msg.note))
195
+ else:
196
+ note_on_msg = pending_notes[msg.note]
197
+ start_time = note_on_msg.time * 2 # seconds to beat at 120 bpm
198
+ duration = (msg.time - note_on_msg.time) * 2
199
+ new_note = Note(note_on_msg.note, note_on_msg.velocity, duration, start_time)
200
+ sequence.append(new_note)
201
+ self.set_delta_notes(sequence)
202
+ return sequence
203
+
204
+ @staticmethod
205
+ def transpose_notes(notes, t):
206
+ return [n.transpose(t) for n in notes]
207
+
208
+ def get_input_note(self, note_address):
209
+ # note_address is a tuple (melody index, index in melody)
210
+ return self.vom.get_input_object(note_address)
211
+
212
+ def is_starting_address(self, note_address):
213
+ return self.vom.is_starting_address(note_address)
214
+
215
+ def is_ending_address(self, note_address):
216
+ return self.vom.is_ending_address(note_address)
217
+
218
+ def get_start_vp(self):
219
+ return self.vom.start_padding
220
+
221
+ def get_end_vp(self):
222
+ return self.vom.end_padding
223
+
224
+ # @ time in midifile is expressed in ticks with some resolution. We convert it into beats, assuming 120bpm
225
+ def extract_notes(self, midi_file):
226
+ """Extracts the sequence of note-on events from a MIDI file."""
227
+ mid = mido.MidiFile(midi_file)
228
+ resolution = mid.ticks_per_beat
229
+ notes = []
230
+ pending_notes = np.empty(128, dtype=object)
231
+ pending_start_times = np.zeros(128)
232
+ current_time = 0
233
+ for track in mid.tracks:
234
+ for msg in track:
235
+ current_time += 2 * mido.tick2second(msg.time, ticks_per_beat=resolution, tempo=500000) # in beats
236
+ if msg.type == 'set_tempo':
237
+ self.tempo_msgs.append(msg.tempo)
238
+ if msg.type == "note_on" and msg.velocity > 0:
239
+ new_note = Note(msg.note, msg.velocity, 0)
240
+ notes.append(new_note) # Store MIDI note number
241
+ pending_notes[msg.note] = new_note
242
+ pending_start_times[msg.note] = current_time
243
+ new_note.set_start_time(current_time)
244
+ new_note.set_duration(1) # beat
245
+ if msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0):
246
+ if pending_notes[msg.note] is None:
247
+ print("found 0 velocity note, skipping it")
248
+ continue
249
+ pending_note = pending_notes[msg.note]
250
+ duration = current_time - pending_start_times[msg.note]
251
+ pending_note.set_duration(duration)
252
+ pending_notes[msg.note] = None
253
+ pending_start_times[msg.note] = 0
254
+ # sets the note status w/r their neighbors
255
+ self.set_delta_notes(notes)
256
+ return np.array(notes)
257
+
258
+ @staticmethod
259
+ def set_delta_notes(notes):
260
+ for i, note in enumerate(notes):
261
+ if i > 0:
262
+ note.preceding_start_delta = note.start_time - notes[i - 1].start_time
263
+ note.preceding_end_delta = note.start_time - notes[i - 1].get_end_time()
264
+ if i < len(notes) - 1:
265
+ note.next_start_delta = notes[i + 1].start_time - note.get_end_time()
266
+ note.next_end_delta = notes[i + 1].get_end_time() - note.get_end_time()
267
+
268
+ @staticmethod
269
+ def all_midi_files_from_path(path_string):
270
+ path = pathlib.Path(path_string)
271
+ return list(path.glob('*.mid')) + list(path.glob('*.midi'))
272
+
273
+ def sample_sequence(self, prefix = None, length=50, constraints=None):
274
+ """
275
+ :param length:
276
+ :type constraints: dict
277
+ """
278
+ return self.vom.sample_sequence(length, prefix = prefix, constraints=constraints)
279
+
280
+ def sample_sequence_0(self, length=50, constraints=None):
281
+ """
282
+ :param length:
283
+ :type constraints: dict
284
+ """
285
+ return self.vom.sample_zero_order(length, constraints=constraints)
286
+
287
+ def realize_vp_sequence(self, vp_seq):
288
+ # print(f"realize sequence of {len(vp_seq)} viewpoints")
289
+ note_sequence = []
290
+ for i, vp in enumerate(vp_seq):
291
+ if i == 0:
292
+ initials = [real for real in self.vom.viewpoints_realizations[vp] if self.is_starting_address(real)]
293
+ if len(initials) != 0:
294
+ note_sequence.append(random.choice(initials))
295
+ continue
296
+ if i == len(vp_seq) - 1 and vp_seq[-1] == self.vom.end_padding:
297
+ lasts = [real for real in self.vom.viewpoints_realizations[vp] if self.is_ending_address(real)]
298
+ if len(lasts) != 0:
299
+ note_sequence.append(random.choice(lasts))
300
+ continue
301
+ note_sequence.append(random.choice(self.vom.viewpoints_realizations[vp]))
302
+
303
+ # domains = [self.viewpoints_realizations[vp] for vp in vp_seq]
304
+ # # # try to put together notes with compatible status @TODO
305
+ # unary_cost = lambda i, real: 0
306
+ # binary_cost = lambda i, real1, j, real2: (int)(not self.get_input_note(real1).is_compatible_with(self.get_input_note(real2)))
307
+ # optimizer = VariableDomainSequenceOptimizer(domains, unary_cost, binary_cost)
308
+ # cost, best_seq = optimizer.fit()
309
+
310
+ result = self.set_timing(note_sequence)
311
+ return result
312
+ # return best_seq
313
+
314
+ def get_vp_for_pitch(self, pitch):
315
+ # this is way too costly, but used only at constraint initialization. Can be cached
316
+ vps = []
317
+ for vp, notes in self.vom.viewpoints_realizations.items():
318
+ for note_address in notes:
319
+ note = self.vom.get_input_object(note_address)
320
+ if note.pitch == pitch:
321
+ vps.append(vp)
322
+ return random.choice(vps)
323
+
324
+ def set_timing(self, idx_sequence):
325
+ sequence = []
326
+ start_time = 0
327
+ for i, note_address in enumerate(idx_sequence):
328
+ note_copy = self.get_input_note(note_address).copy()
329
+ # keeps the inter note time to be the same as in the original sequence
330
+ if len(sequence) > 0:
331
+ preceding = sequence[-1]
332
+ preceding_address = idx_sequence[i - 1]
333
+ delta = self.decide_delta_time(note_address, note_copy, preceding_address, preceding)
334
+ start_time += delta
335
+ note_copy.set_start_time(start_time)
336
+ sequence.append(note_copy)
337
+ # shift the whole sequence to t=0
338
+ first_note_time = sequence[0].start_time
339
+ for note in sequence:
340
+ note.start_time = note.start_time - first_note_time
341
+ return sequence
342
+
343
+ @staticmethod
344
+ def get_pitch_string(note_sequence):
345
+ return "".join([str(note.pitch) + " " for note in note_sequence])
346
+
347
+ @staticmethod
348
+ def decide_delta_time(note_to_add_address, note_to_add, current_address, current_note):
349
+ if current_note is None:
350
+ return 0
351
+ cur_status = current_note.get_status_right()
352
+ note_to_add_status = note_to_add.get_status_left()
353
+ delta = current_note.duration + current_note.next_start_delta
354
+ if cur_status == "inside":
355
+ if note_to_add_status == "before":
356
+ return delta
357
+ if note_to_add_status == "overlaps":
358
+ return delta
359
+ if note_to_add_status == "contains":
360
+ return delta
361
+ if cur_status == "overlaps":
362
+ if note_to_add_status == "before":
363
+ return delta
364
+ if note_to_add_status == "overlaps":
365
+ return delta
366
+ if note_to_add_status == "contains":
367
+ return delta
368
+ if cur_status == "after":
369
+ if note_to_add_status == "before":
370
+ return delta
371
+ if note_to_add_status == "overlaps":
372
+ return delta
373
+ if note_to_add_status == "contains":
374
+ return delta
375
+ print("should not be here")
376
+ return 0
377
+
378
+ def save_midi(self, sequence, output_file, tempo=120, sustain=False):
379
+ ms = self.create_mido_sequence(sequence, tempo=tempo, sustain=sustain)
380
+ ms.save(output_file)
381
+
382
+ def create_mido_sequence(self, sequence, tempo=120, sustain=False):
383
+ mid = mido.MidiFile()
384
+ track = mido.MidiTrack()
385
+ mid.tracks.append(track)
386
+ # create a new sequence with the right start_times
387
+ # create all mido messages and sort them
388
+ mido_sequence = []
389
+ for note in sequence:
390
+ try:
391
+ mido_sequence.append(
392
+ mido.Message(
393
+ "note_on",
394
+ note=note.pitch,
395
+ velocity=note.velocity,
396
+ time=note.start_time,
397
+ )
398
+ )
399
+ except:
400
+ print("Something went wrong")
401
+ mido_sequence.append(
402
+ mido.Message(
403
+ "note_off",
404
+ note=note.pitch,
405
+ velocity=0,
406
+ time=note.start_time + note.duration,
407
+ )
408
+ )
409
+ mido_sequence.sort(key=lambda messg: messg.time)
410
+ if sustain:
411
+ # add pedal message
412
+ mido_sequence.insert(0, mido.Message(
413
+ "control_change",
414
+ control=64,
415
+ value=127,
416
+ time=0,
417
+ ))
418
+ if tempo == -1 and len(self.tempo_msgs) > 0:
419
+ # takes the original average tempo
420
+ average_tempo = int(np.sum(self.tempo_msgs) / len(self.tempo_msgs))
421
+ mido_sequence.insert(0, mido.MetaMessage(type='set_tempo', tempo=average_tempo))
422
+ current_time = 0
423
+ # converts beats into ticks, assuming 480 ticks per second
424
+ for msg in mido_sequence:
425
+ delta_in_beats = msg.time - current_time
426
+ delta_in_ticks = int(mid.ticks_per_beat * delta_in_beats)
427
+ msg.time = delta_in_ticks
428
+ track.append(msg)
429
+ current_time += delta_in_beats
430
+ return mid
431
+
432
+ def get_longest_subsequence_with_train(self, address_sequence):
433
+ note_sequence = [self.get_input_note(address) for address in address_sequence]
434
+ sequence_string = self.get_pitch_string(note_sequence)
435
+ best = 0
436
+ for input_seq in self.vom.input_sequences:
437
+ train_string = self.get_pitch_string(input_seq)
438
+ match = SequenceMatcher(
439
+ None, train_string, sequence_string, autojunk=False
440
+ ).find_longest_match()
441
+ nb_notes_common = train_string[match.a: match.a + match.size].count(" ")
442
+ if nb_notes_common > best:
443
+ best = nb_notes_common
444
+ return best
445
+
446
+
447
+
448
+ if __name__ == '__main__':
449
+ # midi_file_path = "../../data/Ravel_jeaux_deau.mid"
450
+ # midi_file_path = "../../data/test_sequence_3notes.mid"
451
+ # midi_file_path = "../../data/test_sequence_arpeggios.mid"
452
+ # midi_file_path = "../../data/debussy_prelude.mid"
453
+ # midi_file_path = "../../data/prelude_c_expressive.mid"
454
+ # midi_file_path = "../../data/prelude_c_linear.mid"
455
+ # midi_file_path = "../../data/partita_piano_1/pr1_1_joined.mid"
456
+ # midi_file_path = "../../data/take6/A_quiet_place_joined.mid"
457
+ # midi_file_path = "../../data/prelude_c_expressive.mid"
458
+ midi_file_path = "../data/prelude_c.mid"
459
+ # midi_file_path = "../../data/bach_partita_mono.midi"
460
+ # midi_file_path = "../../data/keith/train/K7_MD.mid"
461
+ # midi_file_path = "../../../maestro-v3.0.0/2004/MIDI-Unprocessed_SMF_12_01_2004_01-05_ORIG_MID--AUDIO_12_R1_2004_03_Track03_wav--1.midi"
462
+ t0 = time.perf_counter_ns()
463
+ generator = Continuator2(midi_file_path, 4, transposition=False)
464
+ # matrix = generator.get_first_order_matrix()
465
+ # print(matrix.shape)
466
+ # t1 = time.perf_counter_ns()
467
+ # print(f"total time: {(t1 - t0) / 1000000}")
468
+ # Sampling a new sequence from the model
469
+ constraints = {0: generator.get_vp_for_pitch(62), 19: generator.get_end_vp()}
470
+ # constraints[0] = generator.get_start_vp()
471
+ generated_sequence = generator.sample_sequence(length=20, constraints=constraints)
472
+ t1 = time.perf_counter_ns()
473
+ print(f"total time: {(t1 - t0) / 1_000_000}ms")
474
+ # print(f"generated sequence of length {len(generated_sequence)}")
475
+ sequence_to_render = generated_sequence[0:-1]
476
+ rendered_sequence = generator.realize_vp_sequence(sequence_to_render)
477
+ generator.save_midi(rendered_sequence, "../data/ctor2_output.mid", tempo=-1, sustain=False)
478
+ # pmpr = generator.create_pr®etty_midi_pr(generated_sequence)
479
+ # generator.plot_piano_roll(pmpr)
480
+ # os.system("say sequence generated &")
481
+ # print("Generated Sequence:", generated_sequence)
482
+ # print("computing plagiarism:")
483
+ # print(
484
+ # f"{generator.get_longest_subsequence_with_train(generated_sequence)} successive notes in commun with train"
485
+ # )
486
+ generator.vom.show_conts_structure()
backend/vendor/ctor/dynaprog.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Copyright (c) 2025 Ynosound.
3
+ All rights reserved.
4
+
5
+ See LICENSE file in the project root for full license information.
6
+ """
7
+
8
+ import numpy as np
9
+
10
+
11
+ class VariableDomainSequenceOptimizer:
12
+ """
13
+ A class for solving sequence assignment problems with variable domains:
14
+
15
+ We have positions i = 0..n-1, each with its own domain[i].
16
+ We want to minimize:
17
+ sum_{i=0}^{n-1} unary_cost(i, x_i)
18
+ + sum_{i=0}^{n-2} binary_cost(i, x_i, i+1, x_{i+1})
19
+
20
+ using dynamic programming, supporting different domain sizes per position.
21
+ """
22
+
23
+ def __init__(self, domains, unary_cost, binary_cost):
24
+ """
25
+ Parameters
26
+ ----------
27
+ domains : list of lists
28
+ domains[i] is the list of allowable labels for position i.
29
+ E.g., domains[0] = [0,1,2], domains[1] = ['A','B'], etc.
30
+ unary_cost : function (i, x) -> float
31
+ A function that gives the cost of assigning value x at position i.
32
+ binary_cost : function (i, x, i+1, y) -> float
33
+ A function that gives the cost of assigning x at position i and y at position i+1.
34
+ """
35
+ # n is the total number of positions
36
+ self.n = len(domains)
37
+ # domains[i] is a list of valid labels for position i
38
+ self.domains = domains
39
+ # cost functions
40
+ self.unary_cost_func = unary_cost
41
+ self.binary_cost_func = binary_cost
42
+
43
+ # Precompute unary arrays: U[i], shape (|D_i|,)
44
+ self.U = self._compute_unary_arrays()
45
+
46
+ # Precompute binary arrays: B[i], shape (|D_i|, |D_{i+1}|)
47
+ if self.n > 1:
48
+ self.B = self._compute_binary_arrays()
49
+ else:
50
+ self.B = [] # no binary cost if only one position
51
+
52
+ # DP tables (filled by fit)
53
+ # dp[i] will be shape (|D_i|,)
54
+ # backpointer[i] will be shape (|D_i|,) storing the chosen index in domain[i+1]
55
+ self.dp = [None] * self.n
56
+ self.backpointer = [None] * self.n
57
+
58
+ def _compute_unary_arrays(self):
59
+ """
60
+ For each position i, create a 1D array of shape (|D_i|,)
61
+ where U[i][d] = unary_cost_func(i, domains[i][d]).
62
+ """
63
+ U = []
64
+ for i in range(self.n):
65
+ dom_i = self.domains[i]
66
+ arr = np.zeros(len(dom_i), dtype=np.float64)
67
+ for d, label in enumerate(dom_i):
68
+ arr[d] = self.unary_cost_func(i, label)
69
+ U.append(arr)
70
+ return U
71
+
72
+ def _compute_binary_arrays(self):
73
+ """
74
+ For each i in [0..n-2], create a 2D array B[i] of shape (|D_i|, |D_{i+1}|)
75
+ where B[i][d1, d2] = binary_cost_func(i, domains[i][d1], i+1, domains[i+1][d2]).
76
+ """
77
+ B = []
78
+ for i in range(self.n - 1):
79
+ dom_i = self.domains[i]
80
+ dom_next = self.domains[i + 1]
81
+ mat = np.zeros((len(dom_i), len(dom_next)), dtype=np.float64)
82
+ for d1, label1 in enumerate(dom_i):
83
+ for d2, label2 in enumerate(dom_next):
84
+ mat[d1, d2] = self.binary_cost_func(i, label1, i + 1, label2)
85
+ B.append(mat)
86
+ return B
87
+
88
+ def fit(self):
89
+ """
90
+ Run the dynamic programming to find the minimum total cost and the best assignment.
91
+
92
+ Returns
93
+ -------
94
+ (min_cost, best_sequence)
95
+ min_cost : float
96
+ The minimal total cost.
97
+ best_sequence : list
98
+ A list of length n with the optimal label for each position.
99
+ """
100
+ # If n == 0, trivial
101
+ if self.n == 0:
102
+ return 0.0, []
103
+
104
+ # dp[i], shape (|D_i|,) -> minimal cost from position i onward if x_i = domain[i][d]
105
+ # backpointer[i], shape (|D_i|,) -> best index in domain[i+1] for each d in domain[i].
106
+
107
+ # Base case: i = n-1
108
+ self.dp[self.n - 1] = self.U[self.n - 1].copy()
109
+ self.backpointer[self.n - 1] = np.full(len(self.domains[self.n - 1]), -1, dtype=np.int64)
110
+
111
+ # Fill backward from i = n-2 down to 0
112
+ for i in range(self.n - 2, -1, -1):
113
+ dom_i_size = len(self.domains[i])
114
+ dom_next_size = len(self.domains[i + 1])
115
+
116
+ dp_i = np.zeros(dom_i_size, dtype=np.float64)
117
+ bp_i = np.zeros(dom_i_size, dtype=np.int64)
118
+
119
+ # cost_matrix = B[i] + dp[i+1]
120
+ # B[i] is shape (dom_i_size, dom_next_size)
121
+ # dp[i+1] is shape (dom_next_size,)
122
+ # so cost_matrix is shape (dom_i_size, dom_next_size), where
123
+ # cost_matrix[d1, d2] = B[i][d1, d2] + dp[i+1][d2]
124
+ cost_matrix = self.B[i] + self.dp[i + 1]
125
+
126
+ # For each d1 in [0..dom_i_size-1], we find the minimal cost over d2
127
+ # min_costs[d1] = min_{d2} [ cost_matrix[d1, d2] ]
128
+ # best_next[d1] = argmin_{d2} [ cost_matrix[d1, d2] ]
129
+ min_costs = np.min(cost_matrix, axis=1)
130
+ best_next = np.argmin(cost_matrix, axis=1)
131
+
132
+ dp_i[:] = self.U[i] + min_costs
133
+ bp_i[:] = best_next
134
+
135
+ self.dp[i] = dp_i
136
+ self.backpointer[i] = bp_i
137
+
138
+ # Find the best start label at i=0
139
+ min_cost = np.min(self.dp[0])
140
+ best_start = np.argmin(self.dp[0])
141
+
142
+ # Reconstruct solution
143
+ best_sequence = [None] * self.n
144
+ best_sequence[0] = self.domains[0][best_start]
145
+
146
+ prev_index = best_start
147
+ for i in range(0, self.n - 1):
148
+ next_index = self.backpointer[i][prev_index]
149
+ best_sequence[i + 1] = self.domains[i + 1][next_index]
150
+ prev_index = next_index
151
+
152
+ return min_cost, best_sequence
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Example usage:
157
+ if __name__ == "__main__":
158
+ # Suppose we have 4 positions, each with a different domain of labels:
159
+ domains = [
160
+ [0, 1], # position 0
161
+ [0, 1, 2], # position 1
162
+ ['A', 'B'], # position 2
163
+ [10, 20, 30] # position 3
164
+ ]
165
+
166
+
167
+ # A simple unary cost function that depends on i and x
168
+ def unary_cost(i, x):
169
+ # e.g., cost is i * int(x != 0) just as a silly example
170
+ # for non-integer x, we'll treat 'A'/'B' or whatever carefully
171
+ return 1.0 if x != 0 else 0.0
172
+
173
+
174
+ # A simple binary cost function
175
+ def binary_cost(i, x, j, y):
176
+ # For demonstration, let's say cost = 1 if x == y, else 0
177
+ return float(x == y)
178
+
179
+
180
+ optimizer = VariableDomainSequenceOptimizer(domains, unary_cost, binary_cost)
181
+ cost, best_seq = optimizer.fit()
182
+
183
+ print("Minimal cost:", cost)
184
+ print("Best sequence:", best_seq)
backend/vendor/ctor/markov_analysis.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # markov_ergodicity.py
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+ from typing import List, Optional, Tuple
5
+ import numpy as np
6
+ import math
7
+
8
+ @dataclass
9
+ class MarkovAnalysis:
10
+ n_states: int
11
+ irreducible: bool
12
+ period: Optional[int]
13
+ aperiodic: bool
14
+ ergodic: bool
15
+ stationary_distribution: np.ndarray
16
+ sccs: List[List[int]]
17
+ primitive_k: Optional[int]
18
+
19
+ def _validate_row_stochastic(P: np.ndarray, tol: float) -> None:
20
+ if P.ndim != 2 or P.shape[0] != P.shape[1]:
21
+ raise ValueError("P must be a square matrix.")
22
+ if (P < -tol).any():
23
+ raise ValueError("P must have nonnegative entries (within tolerance).")
24
+ row_sums = P.sum(axis=1)
25
+ if not np.allclose(row_sums, 1.0, atol=1e-9):
26
+ raise ValueError("Each row of P must sum to 1 (within tolerance).")
27
+
28
+ def _adjacency(P: np.ndarray, tol: float) -> Tuple[List[List[int]], List[List[int]]]:
29
+ n = P.shape[0]
30
+ adj = [[] for _ in range(n)]
31
+ radj = [[] for _ in range(n)]
32
+ for i in range(n):
33
+ for j in range(n):
34
+ if P[i, j] > tol:
35
+ adj[i].append(j)
36
+ radj[j].append(i)
37
+ return adj, radj
38
+
39
+ def _kosaraju_scc(adj: List[List[int]], radj: List[List[int]]) -> Tuple[List[List[int]], List[int]]:
40
+ n = len(adj)
41
+ visited = [False] * n
42
+ order: List[int] = []
43
+
44
+ def dfs1(u: int):
45
+ stack = [u]
46
+ while stack:
47
+ v = stack.pop()
48
+ if v < 0:
49
+ order.append(~v)
50
+ continue
51
+ if visited[v]:
52
+ continue
53
+ visited[v] = True
54
+ stack.append(~v)
55
+ for w in adj[v]:
56
+ if not visited[w]:
57
+ stack.append(w)
58
+
59
+ for v in range(n):
60
+ if not visited[v]:
61
+ dfs1(v)
62
+
63
+ comp = [-1] * n
64
+ cid = 0
65
+ for v in reversed(order):
66
+ if comp[v] != -1:
67
+ continue
68
+ stack = [v]
69
+ comp[v] = cid
70
+ while stack:
71
+ x = stack.pop()
72
+ for w in radj[x]:
73
+ if comp[w] == -1:
74
+ comp[w] = cid
75
+ stack.append(w)
76
+ cid += 1
77
+
78
+ sccs: List[List[int]] = [[] for _ in range(cid)]
79
+ for v in range(n):
80
+ sccs[comp[v]].append(v)
81
+ return sccs, comp
82
+
83
+ def _is_irreducible(P: np.ndarray, tol: float) -> Tuple[bool, List[List[int]], List[int], List[List[int]]]:
84
+ adj, radj = _adjacency(P, tol)
85
+ sccs, comp = _kosaraju_scc(adj, radj)
86
+ return (len(sccs) == 1), sccs, comp, adj
87
+
88
+ def _period_of_irreducible(adj: List[List[int]]) -> int:
89
+ n = len(adj)
90
+ dist = [-1] * n
91
+ g = 0
92
+ dist[0] = 0
93
+ stack = [0]
94
+ while stack:
95
+ u = stack.pop()
96
+ for v in adj[u]:
97
+ if dist[v] == -1:
98
+ dist[v] = dist[u] + 1
99
+ stack.append(v)
100
+ else:
101
+ g = math.gcd(g, abs(dist[u] + 1 - dist[v]))
102
+ return max(1, g)
103
+
104
+ def _stationary_distribution(P: np.ndarray, tol: float) -> np.ndarray:
105
+ n = P.shape[0]
106
+ A = np.eye(n) - P.T
107
+ A[-1, :] = 1.0
108
+ b = np.zeros(n); b[-1] = 1.0
109
+ pi, *_ = np.linalg.lstsq(A, b, rcond=None)
110
+ pi = np.maximum(pi, 0.0)
111
+ s = pi.sum()
112
+ if s <= tol:
113
+ w, v = np.linalg.eig(P.T)
114
+ i = int(np.argmin(np.abs(w - 1.0)))
115
+ pi = np.real(v[:, i])
116
+ pi = np.maximum(pi, 0.0)
117
+ s = pi.sum()
118
+ if s <= tol:
119
+ raise ValueError("Could not compute a valid stationary distribution.")
120
+ return pi / pi.sum()
121
+
122
+ def _primitive_k(P: np.ndarray, tol: float, max_k: int) -> Optional[int]:
123
+ n = P.shape[0]
124
+ M = P.copy()
125
+ for k in range(1, max_k + 1):
126
+ if (M > tol).all():
127
+ return k
128
+ M = M @ P
129
+ return None
130
+
131
+ def analyze_markov_chain(
132
+ P: np.ndarray,
133
+ tol: float = 1e-12,
134
+ compute_primitive: bool = False,
135
+ max_k: int = 256,
136
+ ) -> MarkovAnalysis:
137
+ P = np.asarray(P, dtype=float)
138
+ _validate_row_stochastic(P, tol)
139
+ irreducible, sccs, comp, adj = _is_irreducible(P, tol)
140
+ if irreducible:
141
+ period = _period_of_irreducible(adj)
142
+ aperiodic = (period == 1)
143
+ ergodic = aperiodic
144
+ else:
145
+ period = None
146
+ aperiodic = False
147
+ ergodic = False
148
+ pi = _stationary_distribution(P, tol)
149
+ prim_k = _primitive_k(P, tol, max_k) if compute_primitive else None
150
+ return MarkovAnalysis(
151
+ n_states=P.shape[0],
152
+ irreducible=irreducible,
153
+ period=period,
154
+ aperiodic=aperiodic,
155
+ ergodic=ergodic,
156
+ stationary_distribution=pi,
157
+ sccs=sccs,
158
+ primitive_k=prim_k,
159
+ )
backend/vendor/ctor/variable_order_markov.py ADDED
@@ -0,0 +1,828 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Copyright (c) 2025 Ynosound.
3
+ All rights reserved.
4
+
5
+ See LICENSE file in the project root for full license information.
6
+ """
7
+
8
+ from collections import Counter, defaultdict
9
+ from typing import Dict, Tuple, Optional
10
+
11
+ import numpy as np
12
+ import random
13
+ from difflib import SequenceMatcher
14
+
15
+ from ctor.belief_propag import PGM, LabeledArray, Messages, NoSolutionErrorInBP
16
+ from ctor.markov_analysis import analyze_markov_chain
17
+
18
+
19
+ # -----------------------------------------------------------------------------
20
+ # Time-decay building blocks
21
+ # -----------------------------------------------------------------------------
22
+
23
+ class LazyExpCounter:
24
+ """
25
+ Per-key lazy exponential counter (exact).
26
+ Stores (weight, last_update_step) for each key and applies decay d**Δ on read or update.
27
+
28
+ data: key(int) -> (weight: float, last_step: int)
29
+ """
30
+ __slots__ = ("decay", "data", "eps")
31
+
32
+ def __init__(self, decay: float, eps: float = 1e-12):
33
+ self.decay = float(decay) # in (0,1], 1.0 = no decay
34
+ self.data: Dict[int, Tuple[float, int]] = {}
35
+ self.eps = eps
36
+
37
+ def update(self, key: int, step: int, incr: float = 1.0) -> None:
38
+ w, last = self.data.get(key, (0.0, step))
39
+ if w > 0.0 and step > last and self.decay < 1.0:
40
+ w *= self.decay ** (step - last)
41
+ w += incr
42
+ self.data[key] = (w, step)
43
+
44
+ def weights(self, now: int) -> Dict[int, float]:
45
+ if not self.data:
46
+ return {}
47
+ if self.decay >= 1.0:
48
+ # no decay, just return current weights
49
+ return {k: w for k, (w, _) in self.data.items() if w > self.eps}
50
+ d = self.decay
51
+ out: Dict[int, float] = {}
52
+ for k, (w, t) in self.data.items():
53
+ cw = w * (d ** (now - t))
54
+ if cw > self.eps:
55
+ out[k] = cw
56
+ return out
57
+
58
+
59
+ class MultiCounter:
60
+ """
61
+ For each (context -> next) relation, maintain:
62
+ - full : exact counts (no decay)
63
+ - slow : lazy exponential counter (long half-life)
64
+ - fast : lazy exponential counter (short half-life)
65
+
66
+ Exposes 4 "views":
67
+ - 'full' : full counts (no decay)
68
+ - 'late' : fast (recent)
69
+ - 'middle' : slow - fast (>=0) -> band around intermediate ages
70
+ - 'early' : full - slow (>=0) -> emphasize older
71
+ """
72
+ __slots__ = ("full", "slow", "fast", "track_decays")
73
+
74
+ def __init__(self, decay_fast: float, decay_slow: float):
75
+ self.full: Counter[int] = Counter()
76
+ self.track_decays = (decay_fast < 1.0) or (decay_slow < 1.0)
77
+ self.fast = LazyExpCounter(decay_fast) if self.track_decays else None
78
+ self.slow = LazyExpCounter(decay_slow) if self.track_decays else None
79
+
80
+ def update(self, nxt: int, step: int, incr: float = 1.0) -> None:
81
+ self.full[nxt] += incr
82
+ if self.track_decays:
83
+ self.fast.update(nxt, step, incr)
84
+ self.slow.update(nxt, step, incr)
85
+
86
+ def weights(self, now: int, mode: str = "full") -> Dict[int, float]:
87
+ if (mode == "full") or (not self.track_decays):
88
+ return {k: float(v) for k, v in self.full.items() if v > 0}
89
+
90
+ if mode == "late":
91
+ return self.fast.weights(now)
92
+
93
+ if mode == "middle":
94
+ f = self.fast.weights(now)
95
+ s = self.slow.weights(now)
96
+ keys = set(f) | set(s)
97
+ out: Dict[int, float] = {}
98
+ for k in keys:
99
+ v = s.get(k, 0.0) - f.get(k, 0.0)
100
+ if v > 0.0:
101
+ out[k] = v
102
+ return out
103
+
104
+ if mode == "early":
105
+ s = self.slow.weights(now)
106
+ keys = set(self.full) | set(s)
107
+ out: Dict[int, float] = {}
108
+ for k in keys:
109
+ v = float(self.full.get(k, 0)) - s.get(k, 0.0)
110
+ if v > 0.0:
111
+ out[k] = v
112
+ return out
113
+
114
+ raise ValueError(f"unknown mode '{mode}'")
115
+
116
+ def nonzero_options(self, now: int, mode: str) -> int:
117
+ return len(self.weights(now, mode))
118
+
119
+
120
+ # -----------------------------------------------------------------------------
121
+ # Sentinels
122
+ # -----------------------------------------------------------------------------
123
+
124
+ class _Start_vp:
125
+ def __init__(self):
126
+ pass
127
+
128
+
129
+ class _End_vp:
130
+ def __init__(self):
131
+ pass
132
+
133
+
134
+ # -----------------------------------------------------------------------------
135
+ # Variable-order Markov with one unified continuation dictionary + MultiCounter
136
+ # -----------------------------------------------------------------------------
137
+
138
+ class Variable_order_Markov:
139
+ def __init__(
140
+ self,
141
+ sequence_of_stuff,
142
+ vp_lambda,
143
+ kmax: int = 5,
144
+ # optional half-lives (in events or seconds) to enable decay views
145
+ decay_fast_half_life: Optional[float] = None,
146
+ decay_slow_half_life: Optional[float] = None,
147
+ seed: Optional[int] = None,
148
+ ):
149
+ # inputs
150
+ self.viewpoint_lambda = vp_lambda
151
+ self.start_padding = _Start_vp()
152
+ self.end_padding = _End_vp()
153
+ self.kmax = int(kmax)
154
+ self.rng = random.Random(seed)
155
+
156
+ # half-life -> decay base d = 2^(-1/H); None/<=0 => d=1 (no decay)
157
+ def _decay_from_half_life(H: Optional[float]) -> float:
158
+ if not H or H <= 0:
159
+ return 1.0
160
+ return 2.0 ** (-1.0 / float(H))
161
+
162
+ self.decay_fast = _decay_from_half_life(decay_fast_half_life)
163
+ # if only fast given, slow defaults ~8x slower (heuristic)
164
+ self.decay_slow = _decay_from_half_life(
165
+ decay_slow_half_life if decay_slow_half_life
166
+ else (decay_fast_half_life * 8 if decay_fast_half_life else None)
167
+ )
168
+
169
+ # selectable "view" for reading transitions: 'full'|'late'|'middle'|'early'
170
+ self.period_mode: str = "full"
171
+
172
+ # global clock for lazy decay
173
+ self.global_step: int = 0
174
+ # if set, freeze decay at this step for reads
175
+ self.decay_freeze_at: Optional[int] = None
176
+
177
+ self.clear_memory()
178
+ if sequence_of_stuff is not None:
179
+ self.learn_sequence(sequence_of_stuff)
180
+
181
+ # ------------------ controls for views/decay ------------------
182
+
183
+ def set_period_mode(self, mode: str) -> None:
184
+ assert mode in ("full", "late", "middle", "early")
185
+ self.period_mode = mode
186
+ self.first_order_matrix = None # force rebuild if necessary
187
+
188
+ def freeze_decay(self) -> None:
189
+ self.decay_freeze_at = self.global_step
190
+ self.first_order_matrix = None
191
+
192
+ def unfreeze_decay(self) -> None:
193
+ self.decay_freeze_at = None
194
+ self.first_order_matrix = None
195
+
196
+ # ------------------ memory / model ------------------
197
+
198
+ def _make_counter(self) -> MultiCounter:
199
+ return MultiCounter(self.decay_fast, self.decay_slow)
200
+
201
+ def clear_memory(self):
202
+ self.input_sequences = []
203
+ self.all_unique_viewpoints = []
204
+ self.vp2index = {}
205
+ self.viewpoints_realizations = defaultdict(list)
206
+
207
+ # ✅ ONE unified dictionary for all context lengths:
208
+ # key = context tuple (length = order), value = MultiCounter
209
+ self.ctx_to_continuations: Dict[Tuple[object, ...], MultiCounter] = defaultdict(self._make_counter)
210
+
211
+ self.first_order_matrix = None
212
+
213
+ def clear_first_N_phrases(self, n):
214
+ if not self.input_sequences:
215
+ print("nothing to remove, memory is empty")
216
+ return
217
+ if len(self.input_sequences) < n:
218
+ print("nothing to remove, memory is less than " + str(n))
219
+ return
220
+ sequences_to_learn = self.input_sequences[n:]
221
+ self.clear_memory()
222
+ for seq in sequences_to_learn:
223
+ self.learn_sequence(seq)
224
+
225
+ def clear_last_phrase(self):
226
+ if not self.input_sequences:
227
+ print("nothing to remove, memory is empty")
228
+ return
229
+ sequences_to_learn = self.input_sequences[:-1]
230
+ self.clear_memory()
231
+ for seq in sequences_to_learn:
232
+ self.learn_sequence(seq)
233
+
234
+ def learn_sequence(self, sequence_of_stuff):
235
+ self.first_order_matrix = None
236
+ self.input_sequences.append(sequence_of_stuff)
237
+ self.build_vo_markov_model(sequence_of_stuff)
238
+
239
+ def get_input_object(self, obj_address):
240
+ # note_address is a tuple (melody index, index in melody)
241
+ return self.input_sequences[obj_address[0]][obj_address[1]]
242
+
243
+ @staticmethod
244
+ def is_starting_address(note_address):
245
+ # 0 is the start_padding, 1 is the first actual item
246
+ return note_address[1] == 1
247
+
248
+ def is_ending_address(self, note_address):
249
+ return note_address[1] == len(self.input_sequences[note_address[0]]) - 2
250
+
251
+ def is_end_padding(self, vp):
252
+ return vp == self.end_padding
253
+
254
+ def voc_size(self):
255
+ return len(self.all_unique_viewpoints)
256
+
257
+ def random_initial_vp(self):
258
+ mc = self.ctx_to_continuations.get((self.start_padding,), None)
259
+ if not mc:
260
+ raise RuntimeError("Model has no start contexts yet.")
261
+ now = self.global_step if self.decay_freeze_at is None else self.decay_freeze_at
262
+ w = mc.weights(now, self.period_mode) or mc.weights(now, "full")
263
+ items, weights = zip(*w.items())
264
+ j = self.rng.choices(items, weights=weights, k=1)[0]
265
+ return self.all_unique_viewpoints[j]
266
+
267
+ def random_vp_with_probs(self, probs):
268
+ idx = np.random.choice(len(probs), p=probs)
269
+ return self.all_unique_viewpoints[idx]
270
+
271
+ def get_all_unique_viewpoints_except_paddings(self):
272
+ sp, ep = self.start_padding, self.end_padding
273
+ return [vp for vp in self.all_unique_viewpoints if vp not in (sp, ep)]
274
+
275
+ def index_of_vp(self, vp):
276
+ return self.vp2index[vp]
277
+
278
+ def build_vo_markov_model(self, real_sequence):
279
+ """Build/accumulate a VO-Markov model up to order kmax using one unified dict."""
280
+ vp_seq = [self.start_padding] + [self.get_viewpoint(obj) for obj in real_sequence] + [self.end_padding]
281
+
282
+ # register unique viewpoints
283
+ for vp in vp_seq:
284
+ if vp not in self.vp2index:
285
+ self.vp2index[vp] = len(self.all_unique_viewpoints)
286
+ self.all_unique_viewpoints.append(vp)
287
+
288
+ # realizations (keep your original addressing)
289
+ sequence_index = len(self.input_sequences) - 1
290
+ for i, vp in enumerate(vp_seq[1:-1]):
291
+ self.add_viewpoint_realization(i, sequence_index, vp)
292
+
293
+ # contexts update (single pass)
294
+ vp2idx = self.vp2index
295
+ n = len(vp_seq)
296
+ kmax_eff = min(self.kmax, n - 1)
297
+ for i in range(1, n): # i is index of "next" token
298
+ nxt_idx = vp2idx[vp_seq[i]]
299
+ max_k = min(kmax_eff, i) # context length up to kmax
300
+ for k in range(1, max_k + 1):
301
+ ctx = tuple(vp_seq[i - k:i])
302
+ self.ctx_to_continuations[ctx].update(nxt_idx, self.global_step)
303
+ # advance global time once per token
304
+ self.global_step += 1
305
+
306
+ # ensure end->end exists (so rows aren’t empty)
307
+ self.ctx_to_continuations[(self.end_padding,)].update(vp2idx[self.end_padding], self.global_step)
308
+ self.global_step += 1
309
+
310
+ self.first_order_matrix = None
311
+
312
+ # shows an analysis of the markov chain
313
+ # first_order = self.get_first_order_matrix_no_paddings()
314
+ # ma = analyze_markov_chain(first_order, tol=1e-12, compute_primitive=False, max_k=256)
315
+ # print(ma)
316
+
317
+
318
+ # ------------------ priors / zero-order ------------------
319
+
320
+ def get_priors(self):
321
+ key_counts = {key: len(continuations) for key, continuations in self.viewpoints_realizations.items()}
322
+ total_count = sum(key_counts.values())
323
+ priors = {key: count / total_count for key, count in key_counts.items()} if total_count > 0 else {}
324
+ sorted_keys = self.get_all_unique_viewpoints_except_paddings()
325
+ probability_vector = np.array([priors.get(key, 0.0) for key in sorted_keys], dtype=float)
326
+ return probability_vector
327
+
328
+ def sample_zero_order(self, k, constraints=None):
329
+ priors = self.get_priors()
330
+ return random.choices(self.get_all_unique_viewpoints_except_paddings(), weights=priors, k=k)
331
+
332
+ # ------------------ realizations ------------------
333
+
334
+ def add_viewpoint_realization_old(self, i, sequence_index, vp):
335
+ new_address = (sequence_index, i)
336
+ self.viewpoints_realizations[vp].append(new_address)
337
+
338
+ def add_viewpoint_realization_new(self, i, sequence_index, vp):
339
+ new_address = (sequence_index, i)
340
+ if self.is_starting_address(new_address) or self.is_ending_address(new_address):
341
+ self.viewpoints_realizations[vp].append(new_address)
342
+ return
343
+ new_note = self.get_input_object(new_address)
344
+ for real in self.viewpoints_realizations[vp]:
345
+ real_note = self.get_input_object(real)
346
+ if real_note.is_similar_realization(new_note):
347
+ return
348
+ self.viewpoints_realizations[vp].append(new_address)
349
+
350
+ add_viewpoint_realization = add_viewpoint_realization_old
351
+
352
+ # ------------------ matrices ------------------
353
+
354
+ def get_first_order_matrix_old(self):
355
+ """
356
+ Legacy-style matrix from FULL counts on order-1 contexts, using the unified dictionary.
357
+ """
358
+ keys = self.all_unique_viewpoints
359
+ n = len(keys)
360
+ result = np.zeros((n, n), dtype=float)
361
+ now = self.global_step if self.decay_freeze_at is None else self.decay_freeze_at
362
+ for i_vp, vp in enumerate(keys):
363
+ mc = self.ctx_to_continuations.get((vp,), None)
364
+ if not mc:
365
+ continue
366
+ row_counts = mc.weights(now, "full")
367
+ for j, c in row_counts.items():
368
+ result[i_vp, j] = c
369
+ s = result[i_vp].sum()
370
+ if s > 0:
371
+ result[i_vp] /= s
372
+ return result
373
+
374
+ def get_first_order_matrix(self):
375
+ """
376
+ Build the order-1 transition matrix P[i, j] with the current period_mode.
377
+ Caches only in 'full' mode (time-invariant). Decayed/band views depend on 'now'.
378
+ """
379
+ if self.first_order_matrix is not None and self.period_mode == "full":
380
+ return self.first_order_matrix
381
+
382
+ keys = self.all_unique_viewpoints
383
+ n = len(keys)
384
+ counts = np.zeros((n, n), dtype=float)
385
+ now = self.global_step if self.decay_freeze_at is None else self.decay_freeze_at
386
+
387
+ for i, vp in enumerate(keys):
388
+ mc = self.ctx_to_continuations.get((vp,), None)
389
+ if not mc:
390
+ continue
391
+ w = mc.weights(now, self.period_mode)
392
+ if not w:
393
+ continue
394
+ row = counts[i]
395
+ for j, c in w.items():
396
+ row[j] = float(c)
397
+
398
+ # Normalize rows
399
+ row_sums = counts.sum(axis=1, keepdims=True)
400
+ result = np.zeros_like(counts)
401
+ np.divide(counts, row_sums, out=result, where=row_sums > 0)
402
+
403
+ self.first_order_matrix = result if self.period_mode == "full" else None
404
+ # ma = analyze_markov_chain(result, tol=1e-12, compute_primitive=False, max_k=256)
405
+ # print(ma)
406
+ return result
407
+
408
+ def get_first_order_matrix_no_paddings(self):
409
+ """
410
+ Build the order-1 transition matrix P[i, j] with the current period_mode.
411
+ Caches only in 'full' mode (time-invariant). Decayed/band views depend on 'now'.
412
+ """
413
+
414
+ keys = self.get_all_unique_viewpoints_except_paddings()
415
+ n = len(keys)
416
+ counts = np.zeros((n, n), dtype=float)
417
+ now = self.global_step if self.decay_freeze_at is None else self.decay_freeze_at
418
+
419
+ for i, vp in enumerate(keys):
420
+ mc = self.ctx_to_continuations.get((vp,), None)
421
+ if not mc:
422
+ continue
423
+ w = mc.weights(now, self.period_mode)
424
+ if not w:
425
+ continue
426
+ row = counts[i]
427
+ for j, c in w.items():
428
+ # if j > n, it means it is a start or end vp, so should skip
429
+ if j < n:
430
+ row[j] = float(c)
431
+
432
+ # Normalize rows
433
+ row_sums = counts.sum(axis=1, keepdims=True)
434
+ result = np.zeros_like(counts)
435
+ np.divide(counts, row_sums, out=result, where=row_sums > 0)
436
+ return result
437
+
438
+ # ------------------ viewpoints & sampling ------------------
439
+
440
+ def get_viewpoint(self, real_object):
441
+ if self.viewpoint_lambda is None:
442
+ return real_object
443
+ return self.viewpoint_lambda(real_object)
444
+
445
+ def get_realizations_for_vp(self, vp):
446
+ return self.viewpoints_realizations[vp]
447
+
448
+ def random_starting_note(self):
449
+ # unchanged (uses your previous address scheme)
450
+ starting_vp = (-1, 0)
451
+ starting_conts = self.get_realizations_for_vp(starting_vp)
452
+ start = random.choice(starting_conts)
453
+ return start
454
+
455
+ # def sample_sequence_that_ends(self, start_vp, length=50):
456
+ # pgm = self.build_bp_graph(length)
457
+ # pgm.set_value('x1', self.index_of_vp(start_vp))
458
+ # pgm.set_value('x' + str(length + 2), self.index_of_vp(self.end_padding))
459
+ # try:
460
+ # vp_seq = self.sample_vp_sequence_with_bp(start_vp, length, pgm)
461
+ # except NoSolutionError:
462
+ # return None
463
+ # return vp_seq
464
+
465
+ from typing import Dict, Optional
466
+ # assumes: from ctor.belief_propag import NoSolutionError
467
+
468
+ def sample_sequence_old(
469
+ self,
470
+ length: int,
471
+ prefix=None,
472
+ constraints: Optional[Dict[int, object]] = None,
473
+ *,
474
+ relax_prefix_on_fail: bool = True,
475
+ relax_pos0_on_fail: bool = True,
476
+ raise_on_fail: bool = False,
477
+ ):
478
+ """
479
+ Sample a viewpoint sequence of given `length`.
480
+
481
+ Parameters
482
+ ----------
483
+ prefix : sequence of notes/viewpoints forming the *last played* phrase.
484
+ Used only to derive a soft start bias (start_vp = viewpoint of prefix[-1]).
485
+ constraints : dict[int -> viewpoint]
486
+ Hard constraints at positions (0-based). Values are viewpoint objects (NOT indices).
487
+
488
+ Relaxation logic (if NoSolutionError):
489
+ 1) Try with all constraints + soft start bias from prefix (or from constraints[0] if present).
490
+ 2) If fail and relax_prefix_on_fail: retry with start bias removed.
491
+ 3) If still fail and relax_pos0_on_fail and 0 in constraints: drop the hard constraint at pos 0, keep bias off.
492
+ """
493
+
494
+ constraints = constraints or {}
495
+
496
+ def _build_graph(active_constraints: Dict[int, object]):
497
+ pgm = self.build_bp_graph(length)
498
+ for pos, vp in active_constraints.items():
499
+ var_name = f"x{pos + 1}"
500
+ pgm.set_value(var_name, self.index_of_vp(vp)) # vp -> index
501
+ return pgm
502
+
503
+ # Soft start bias from the prefix (viewpoint object), unless overridden by a hard constraint at pos 0
504
+ start_vp = None
505
+ if prefix:
506
+ start_vp = self.get_viewpoint(prefix[-1])
507
+ else:
508
+ if 0 in constraints:
509
+ start_vp = constraints[0] # hard constraint at pos0 overrides the soft bias
510
+
511
+ last_error = None
512
+
513
+ # Attempt 1: all constraints + (maybe) start bias
514
+ try:
515
+ pgm = _build_graph(constraints)
516
+ seq = self.sample_vp_sequence_with_bp(length, start_vp, pgm)
517
+ if seq is not None:
518
+ if prefix:
519
+ return seq[1:]
520
+ else:
521
+ return seq
522
+ except NoSolutionErrorInBP as e:
523
+ last_error = e
524
+
525
+ if prefix:
526
+ print('give up prefix constraint (continuation)')
527
+ # Attempt 2: relax the prefix bias only
528
+ if relax_prefix_on_fail and start_vp is not None:
529
+ try:
530
+ pgm = _build_graph(constraints)
531
+ seq = self.sample_vp_sequence_with_bp(length, None, pgm)
532
+ if seq is not None:
533
+ return seq
534
+ except NoSolutionErrorInBP as e:
535
+ last_error = e
536
+
537
+ print('give up start sequence constraint')
538
+ # Attempt 3: also drop the hard constraint at position 0 (if any)
539
+ if relax_pos0_on_fail and 0 in constraints:
540
+ try:
541
+ loosened = {k: v for k, v in constraints.items() if k != 0}
542
+ pgm = _build_graph(loosened)
543
+ seq = self.sample_vp_sequence_with_bp(length, None, pgm)
544
+ if seq is not None:
545
+ return seq
546
+ except NoSolutionErrorInBP as e:
547
+ last_error = e
548
+
549
+ if raise_on_fail:
550
+ raise NoSolutionErrorInBP("No solution after relaxing prefix bias and pos0 constraint.") from last_error
551
+ return None
552
+
553
+ def sample_sequence(
554
+ self,
555
+ length: int,
556
+ prefix=None,
557
+ constraints: Optional[Dict[int, object]] = None,
558
+ *,
559
+ relax_prefix_on_fail: bool = True,
560
+ relax_pos0_on_fail: bool = True,
561
+ raise_on_fail: bool = False,
562
+ ):
563
+ """
564
+ Sample a viewpoint sequence of given `length`.
565
+
566
+ Parameters
567
+ ----------
568
+ prefix : sequence of notes/viewpoints forming the *last played* phrase.
569
+ Used only to derive a soft start bias (start_vp = viewpoint of prefix[-1]).
570
+ constraints : dict[int -> viewpoint]
571
+ Hard constraints at positions (0-based). Values are viewpoint objects (NOT indices).
572
+
573
+ Relaxation logic (if NoSolutionError):
574
+ 1) Try with all constraints + soft start bias from prefix (or from constraints[0] if present).
575
+ 2) If fail and relax_prefix_on_fail: retry with start bias removed.
576
+ 3) If still fail and relax_pos0_on_fail and 0 in constraints: drop the hard constraint at pos 0, keep bias off.
577
+ """
578
+
579
+ constraints = constraints or {}
580
+
581
+ def _build_graph(graph_length, active_constraints: Dict[int, object]):
582
+ pgm = self.build_bp_graph(graph_length)
583
+ for pos, vp in active_constraints.items():
584
+ var_name = f"x{pos + 1}"
585
+ pgm.set_value(var_name, self.index_of_vp(vp)) # vp -> index
586
+ return pgm
587
+
588
+ # Soft start bias from the prefix (viewpoint object), unless overridden by a hard constraint at pos 0
589
+ start_vp = None
590
+ if prefix is None:
591
+ pgm = _build_graph(length, constraints)
592
+ seq = self.sample_vp_sequence_with_bp(length, None, pgm)
593
+ return seq
594
+
595
+ # Attempt 1: all constraints + start bias and translate constraints by 1 and length + 1
596
+ translated_constraints = {k + 1: v for k, v in constraints.items()}
597
+ start_vp = self.get_viewpoint(prefix[-1])
598
+ last_error = None
599
+ try:
600
+ pgm = _build_graph(length + 1, translated_constraints)
601
+ seq = self.sample_vp_sequence_with_bp(length + 1, start_vp, pgm)
602
+ if seq is not None:
603
+ return seq[1:]
604
+ # returns the sequence except the prefix
605
+ except NoSolutionErrorInBP as e:
606
+ last_error = e
607
+
608
+ print('give up prefix constraint (continuation)')
609
+ # Attempt 2: relax the prefix bias only
610
+ if relax_prefix_on_fail:
611
+ try:
612
+ translated_constraints = {k + 1: v for k, v in constraints.items()}
613
+ pgm = _build_graph(length + 1, translated_constraints)
614
+ if 1 in constraints:
615
+ start_vp = constraints[1]
616
+ else:
617
+ start_vp = self.start_padding
618
+ seq = self.sample_vp_sequence_with_bp(length + 1, start_vp, pgm)
619
+ if seq is not None:
620
+ return seq[1:] # returns the sequence except the startvp
621
+ except NoSolutionErrorInBP as e:
622
+ last_error = e
623
+
624
+ print('give up start sequence constraint')
625
+ # Attempt 3: also drop the hard constraint at position 0 (if any)
626
+ if relax_pos0_on_fail and 0 in constraints:
627
+ try:
628
+ loosened = {k: v for k, v in constraints.items() if k != 0}
629
+ pgm = _build_graph(length, loosened)
630
+ seq = self.sample_vp_sequence_with_bp(length, None, pgm)
631
+ if seq is not None:
632
+ return seq
633
+ except NoSolutionErrorInBP as e:
634
+ last_error = e
635
+
636
+ if raise_on_fail:
637
+ raise NoSolutionErrorInBP("No solution after relaxing prefix bias and pos0 constraint.") from last_error
638
+ return None
639
+
640
+ # length of bp graph is length + 2: plus the start (possibly the end of an existing sequence) and plus the end viewpoint
641
+ def build_bp_graph(self, length):
642
+ string = ""
643
+ for i in range(length):
644
+ string = string + "p(x" + str(i + 1) + ")"
645
+ for i in range(2, length + 1):
646
+ string = string + "p(x" + str(i) + "|x" + str(i - 1) + ")"
647
+ pgm = PGM.from_string(string)
648
+ mat = LabeledArray(np.array(self.get_first_order_matrix()).transpose(), ["x2", "x1"], )
649
+ m = self.voc_size()
650
+ data_dict = {}
651
+ for i in range(length):
652
+ variable_dist = np.random.uniform(1 / m, 1 / m, m)
653
+ # should avoid start and end values
654
+ variable_dist[self.index_of_vp(self.start_padding)] = 0
655
+ variable_dist[self.index_of_vp(self.end_padding)] = 0
656
+ variable_dist /= variable_dist.sum()
657
+ data_dict["p(x" + str(i + 1) + ")"] = LabeledArray(np.array(variable_dist), ["x" + str(i + 1)])
658
+ data_dict["p(x" + str(i + 2) + "|x" + str(i + 1) + ")"] = LabeledArray(
659
+ mat.array, ["x" + str(i + 2), "x" + str(i + 1)]
660
+ )
661
+ pgm.set_data(data_dict)
662
+ return pgm
663
+
664
+ @staticmethod
665
+ def is_ok(marginal):
666
+ for x in marginal:
667
+ if np.isnan(x):
668
+ return False
669
+ return True
670
+
671
+ def sample_vp_sequence_with_bp(self, length, first_vp, pgm):
672
+ if length < 0:
673
+ print(f"impossible to sample a sequence of length {length}")
674
+ return None
675
+
676
+ if first_vp is not None:
677
+ current_seq = [first_vp]
678
+ else:
679
+ try:
680
+ marginal_1 = Messages().marginal(pgm.variable_from_name('x1'))
681
+ vp = self.random_vp_with_probs(marginal_1)
682
+ current_seq = [vp]
683
+ except NoSolutionErrorInBP:
684
+ return None
685
+ try:
686
+ pgm.set_value('x1', self.index_of_vp(current_seq[0]))
687
+ except NoSolutionErrorInBP:
688
+ return None
689
+ # generate the rest of the sequence
690
+ first_order_matrix = self.get_first_order_matrix()
691
+ for i in range(length - 1):
692
+ pgm_variable = pgm.variable_from_name('x' + str(i + 2))
693
+ try:
694
+ marginal_i = Messages().marginal(pgm_variable)
695
+ except NoSolutionErrorInBP:
696
+ return None
697
+ markov_proba = first_order_matrix[self.index_of_vp(current_seq[-1])]
698
+ product_proba = marginal_i * markov_proba
699
+ cont = self.get_continuation_with_bp(current_seq, product_proba)
700
+ if cont is None:
701
+ cont = self.random_initial_vp()
702
+ current_seq.append(cont)
703
+ pgm.set_value('x' + str(i + 2), self.index_of_vp(cont))
704
+ return current_seq
705
+
706
+ def sample_vp_sequence(self, first_vp, length, last_vp):
707
+ current_seq = [first_vp]
708
+ if length >= 0:
709
+ for _ in range(length):
710
+ cont = self.get_continuation(current_seq)
711
+ if cont is None:
712
+ cont = self.random_initial_vp()
713
+ current_seq.append(cont)
714
+ return current_seq
715
+ while True:
716
+ cont = self.get_continuation(current_seq)
717
+ if cont is None:
718
+ cont = self.random_initial_vp()
719
+ if cont == last_vp:
720
+ if cont != self.end_padding:
721
+ current_seq.append(cont)
722
+ return current_seq
723
+ current_seq.append(cont)
724
+
725
+ def get_continuation(self, current_seq):
726
+ vp_to_skip = None
727
+ now = self.global_step if self.decay_freeze_at is None else self.decay_freeze_at
728
+ for k in range(self.kmax, 0, -1):
729
+ if k > len(current_seq):
730
+ continue
731
+ ctx = tuple(current_seq[-k:])
732
+ mc = self.ctx_to_continuations.get(ctx)
733
+ if not mc:
734
+ continue
735
+ w = mc.weights(now, self.period_mode)
736
+ if not w:
737
+ continue
738
+ # keep your singleton-skip heuristic
739
+ if len(w) == 1 and k > 1:
740
+ if random.random() > (1 / (k + 1)):
741
+ vp_to_skip = next(iter(w))
742
+ continue
743
+ else:
744
+ vp_to_skip = None
745
+ if vp_to_skip is not None and k > 1 and vp_to_skip in w:
746
+ w = {kk: vv for kk, vv in w.items() if kk != vp_to_skip}
747
+ if not w:
748
+ continue
749
+ items, weights = zip(*w.items())
750
+ j = self.rng.choices(items, weights=weights, k=1)[0]
751
+ return self.all_unique_viewpoints[j]
752
+ print("no continuation found")
753
+ return None
754
+
755
+ def get_continuation_with_bp(self, current_seq, probs):
756
+ vp_to_skip = None
757
+ now = self.global_step if self.decay_freeze_at is None else self.decay_freeze_at
758
+ for k in range(self.kmax, 0, -1):
759
+ if k > len(current_seq):
760
+ continue
761
+ ctx = tuple(current_seq[-k:])
762
+ mc = self.ctx_to_continuations.get(ctx)
763
+ if not mc:
764
+ continue
765
+ w = mc.weights(now, self.period_mode)
766
+ if not w:
767
+ continue
768
+ # mask with BP posterior > 0
769
+ mask = [p > 0 for p in probs]
770
+ w = {j: v for j, v in w.items() if mask[j]}
771
+ if not w:
772
+ continue
773
+ if len(w) == 1 and k > 1:
774
+ if random.random() > (1 / (k + 1)):
775
+ vp_to_skip = next(iter(w))
776
+ continue
777
+ else:
778
+ vp_to_skip = None
779
+ if vp_to_skip is not None and k > 1 and vp_to_skip in w:
780
+ del w[vp_to_skip]
781
+ if not w:
782
+ continue
783
+ items, weights = zip(*w.items())
784
+ j = self.rng.choices(items, weights=weights, k=1)[0]
785
+ return self.all_unique_viewpoints[j]
786
+ print("no continuation found")
787
+ return None
788
+
789
+ def show_conts_structure(self):
790
+ # counts per context length
791
+ by_len = defaultdict(int)
792
+ for ctx in self.ctx_to_continuations.keys():
793
+ by_len[len(ctx)] += 1
794
+ for k in range(1, self.kmax + 1):
795
+ print(f"size of contexts of size {k}: {by_len.get(k, 0)}")
796
+
797
+ # sparsity of order-1
798
+ voc_size = self.voc_size()
799
+ now = self.global_step if self.decay_freeze_at is None else self.decay_freeze_at
800
+ min_size = voc_size
801
+ max_size = 0
802
+ for vp in self.all_unique_viewpoints:
803
+ mc = self.ctx_to_continuations.get((vp,), None)
804
+ csz = mc.nonzero_options(now, self.period_mode) if mc else 0
805
+ min_size = min(min_size, csz)
806
+ max_size = max(max_size, csz)
807
+ print(f"voc size: {voc_size}")
808
+ print(f"min order 1 size: {min_size}, max: {max_size}")
809
+ total = 0
810
+ for k in self.viewpoints_realizations:
811
+ total += len(self.viewpoints_realizations[k])
812
+ print(f"average nb of vp realizations: {total / voc_size if voc_size else 0.0}")
813
+
814
+ if __name__ == '__main__':
815
+ # computes chord sequences of length 8 starting and ending with, say, C and with a F#7 in the middle
816
+ with open('../data/chord_sequences.txt', 'r') as file:
817
+ seqs = file.readlines()[:200]
818
+ seqs = [seq.split(';')[1:-1] for seq in seqs]
819
+ seqs = [[chord.strip() for chord in seq] for seq in seqs]
820
+ vo = Variable_order_Markov(None, None, kmax=3)
821
+ for seq in seqs:
822
+ vo.learn_sequence(seq)
823
+
824
+ length = 8
825
+ for i in range(20):
826
+ seq = vo.sample_sequence(length, constraints={0: vo.get_viewpoint('C'), int(length/2): vo.get_viewpoint('F#7'), length - 1: vo.get_viewpoint('C')})
827
+ result = ' '.join(seq)
828
+ print(result)
backend/vendor/midi_stuff/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Vendored subset of the Continuator midi_stuff package."""
2
+
backend/vendor/midi_stuff/mini_muse.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import mido
2
+ import numpy as np
3
+
4
+
5
+ class Note:
6
+ def __init__(self, pitch, velocity, duration, start_time=0):
7
+ self.pitch = pitch
8
+ self.velocity = velocity
9
+ # the duration in the original sequence in beats, assuming 120bpm
10
+ self.duration = duration
11
+ # the start time in the original sequence in beats, assuming 120bpm
12
+ self.start_time = start_time
13
+ # time between start and the start of preceding note, always > 0
14
+ self.preceding_start_delta = 0 # in beats, assuming 120bpm
15
+ # time between start and the end of preceding note. Negative if overlaps with preceding
16
+ self.preceding_end_delta = 0 # in beats, assuming 120bpm
17
+ # time between start of next note and end. Negative if overlaps with next
18
+ self.next_start_delta = 0 # in beats, assuming 120bpm
19
+ # time between end of next note and end
20
+ self.next_end_delta = 0 # in beats, assuming 120bpm
21
+
22
+ def __str__(self):
23
+ return f"{self.pitch} @ [{self.start_time}, {self.get_end_time()}]"
24
+
25
+ def __repr__(self):
26
+ return f"{self.pitch} @ [{self.start_time}, {self.get_end_time()}]"
27
+
28
+ def set_duration(self, d):
29
+ self.duration = d
30
+
31
+ def set_start_time(self, t):
32
+ self.start_time = t
33
+
34
+ def overlaps_left(self):
35
+ # if overlap is greater than half the duration
36
+ return self.preceding_end_delta < 0
37
+
38
+ def overlaps_right(self):
39
+ # if overlap is greater than half the duration
40
+ return self.next_start_delta < 0
41
+
42
+ def transpose(self, t):
43
+ note = self.copy()
44
+ note.pitch = self.pitch + t
45
+ return note
46
+
47
+ def copy(self):
48
+ new_note = Note(self.pitch, self.velocity, self.duration, start_time=self.start_time)
49
+ new_note.preceding_start_delta = self.preceding_start_delta
50
+ new_note.preceding_end_delta = self.preceding_end_delta
51
+ new_note.next_start_delta = self.next_start_delta
52
+ new_note.next_end_delta = self.next_end_delta
53
+ return new_note
54
+
55
+ def get_end_time(self):
56
+ return self.start_time + self.duration
57
+
58
+ def is_compatible_with(self, note):
59
+ # returns true if self and note have same polyphonic status
60
+ return self.overlaps_right() == note.overlaps_left()
61
+
62
+ def get_status_right(self):
63
+ if self.next_end_delta <= 0:
64
+ return 'inside'
65
+ if self.next_start_delta < 0:
66
+ return 'overlaps'
67
+ return 'after'
68
+
69
+ def get_status_left(self):
70
+ if self.preceding_end_delta >= 0:
71
+ return 'before'
72
+ if abs(self.preceding_end_delta) < self.duration:
73
+ return 'overlaps'
74
+ return 'contains'
75
+
76
+ def is_similar_realization(self, note):
77
+ if self.pitch != note.pitch:
78
+ return False
79
+ if self.velocity != note.velocity:
80
+ return False
81
+ if self.duration != note.duration:
82
+ return False
83
+ if self.preceding_end_delta != note.preceding_end_delta:
84
+ return False
85
+ if self.preceding_start_delta != note.preceding_start_delta:
86
+ return False
87
+ if self.next_start_delta != note.next_start_delta:
88
+ return False
89
+ if self.next_end_delta != note.next_end_delta:
90
+ return False
91
+ return True
92
+
93
+
94
+ class Realized_Chord:
95
+ # is a list of Note
96
+ def __init__(self, notes):
97
+ self.notes = notes
98
+
99
+ def get_highest_pitch(self):
100
+ highest = 0
101
+ for note in self.notes:
102
+ highest = max(highest, note.pitch)
103
+ return highest
104
+
105
+ def get_lowest_pitch(self):
106
+ lowest = 128
107
+ for note in self.notes:
108
+ lowest = min(lowest, note.pitch)
109
+ return lowest
110
+
111
+ def append(self, note):
112
+ self.notes.append(note)
113
+
114
+ def get_nb_notes(self):
115
+ return len(self.notes)
116
+
117
+ def transpose_by(self, i):
118
+ transposed_notes = [n.transpose(i) for n in self.notes]
119
+ return Realized_Chord(transposed_notes)
120
+
121
+ def create_mido_sequence(self):
122
+ mid = mido.MidiFile()
123
+ track = mido.MidiTrack()
124
+ mid.tracks.append(track)
125
+ # create a new sequence with the right start_times
126
+ # create all mido messages and sort them
127
+ mido_sequence = []
128
+ for note in self.notes:
129
+ try:
130
+ mido_sequence.append(
131
+ mido.Message(
132
+ "note_on",
133
+ note=note.pitch,
134
+ velocity=note.velocity,
135
+ time=note.start_time,
136
+ )
137
+ )
138
+ except:
139
+ print("Something went wrong")
140
+ mido_sequence.append(
141
+ mido.Message(
142
+ "note_off",
143
+ note=note.pitch,
144
+ velocity=0,
145
+ time=note.start_time + note.duration,
146
+ )
147
+ )
148
+ mido_sequence.sort(key=lambda messg: messg.time)
149
+ current_time = 0
150
+ # converts beats into ticks, assuming 480 ticks per second
151
+ for msg in mido_sequence:
152
+ delta_in_beats = msg.time - current_time
153
+ delta_in_ticks = int(mid.ticks_per_beat * delta_in_beats)
154
+ msg.time = delta_in_ticks
155
+ track.append(msg)
156
+ current_time += delta_in_beats
157
+ return mid
158
+
159
+ @classmethod
160
+ def extract_notes(cls, midi_file):
161
+ mid = mido.MidiFile(midi_file)
162
+ resolution = mid.ticks_per_beat
163
+ notes = []
164
+ pending_notes = np.empty(128, dtype=object)
165
+ pending_start_times = np.zeros(128)
166
+ current_time = 0
167
+ for track in mid.tracks:
168
+ for msg in track:
169
+ current_time += 2 * mido.tick2second(msg.time, ticks_per_beat=resolution, tempo=500000) # in beats
170
+ if msg.type == "note_on" and msg.velocity > 0:
171
+ new_note = Note(msg.note, msg.velocity, 0)
172
+ notes.append(new_note) # Store MIDI note number
173
+ pending_notes[msg.note] = new_note
174
+ pending_start_times[msg.note] = current_time
175
+ new_note.set_start_time(current_time)
176
+ new_note.set_duration(1) # beat
177
+ if msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0):
178
+ if pending_notes[msg.note] is None:
179
+ print("found 0 velocity note, skipping it")
180
+ continue
181
+ pending_note = pending_notes[msg.note]
182
+ duration = current_time - pending_start_times[msg.note]
183
+ pending_note.set_duration(duration)
184
+ pending_notes[msg.note] = None
185
+ pending_start_times[msg.note] = 0
186
+ return np.array(notes)
187
+
188
+ @classmethod
189
+ def create_chords(cls, midi_file, transpose=False):
190
+ notes = cls.extract_notes(midi_file)
191
+ chords = []
192
+ current_chord = Realized_Chord([])
193
+ max_time_current_chord = 0
194
+ for note in notes:
195
+ if note.start_time > max_time_current_chord:
196
+ if current_chord.get_nb_notes() > 0:
197
+ chords.append(current_chord)
198
+ current_chord = Realized_Chord([note])
199
+ max_time_current_chord = max(max_time_current_chord, note.get_end_time())
200
+ else:
201
+ current_chord.append(note)
202
+ max_time_current_chord = max(max_time_current_chord, note.get_end_time())
203
+ if current_chord.get_nb_notes() > 0:
204
+ chords.append(current_chord)
205
+
206
+ if transpose:
207
+ transposed = []
208
+ for i in range(-5, 7):
209
+ if i != 0:
210
+ transposed = transposed + [ch.transpose_by(i) for ch in chords]
211
+ chords = chords + transposed
212
+ return chords
frontend/app.js ADDED
@@ -0,0 +1,1183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const BROWSER_SYNTH_ID = "__browser_synth__";
2
+ const PHRASE_TIMEOUT_MS = 1000;
3
+
4
+ const elements = {
5
+ serverStatus: document.querySelector("#server-status"),
6
+ sessionStatus: document.querySelector("#session-status"),
7
+ sessionId: document.querySelector("#session-id"),
8
+ midiStatus: document.querySelector("#midi-status"),
9
+ phraseStatus: document.querySelector("#phrase-status"),
10
+ selectedInputName: document.querySelector("#selected-input-name"),
11
+ selectedOutputName: document.querySelector("#selected-output-name"),
12
+ lastMidiEvent: document.querySelector("#last-midi-event"),
13
+ capturedEventCount: document.querySelector("#captured-event-count"),
14
+ capturedNoteCount: document.querySelector("#captured-note-count"),
15
+ generatedEventCount: document.querySelector("#generated-event-count"),
16
+ generatedNoteCount: document.querySelector("#generated-note-count"),
17
+ messageBox: document.querySelector("#message-box"),
18
+ historyList: document.querySelector("#history-list"),
19
+ memoryList: document.querySelector("#memory-list"),
20
+ memorySummary: document.querySelector("#memory-summary"),
21
+ memoryHint: document.querySelector("#memory-hint"),
22
+ memoryRibbon: document.querySelector("#memory-ribbon"),
23
+ inputRoll: document.querySelector("#input-roll"),
24
+ outputRoll: document.querySelector("#output-roll"),
25
+ settingsSummary: document.querySelector("#settings-summary"),
26
+ historyTab: document.querySelector("#history-tab"),
27
+ memoryTab: document.querySelector("#memory-tab"),
28
+ historyPanel: document.querySelector("#history-panel"),
29
+ memoryPanel: document.querySelector("#memory-panel"),
30
+ viewTabs: document.querySelectorAll("[data-view-tab]"),
31
+ midiInputSelect: document.querySelector("#midi-input-select"),
32
+ midiOutputSelect: document.querySelector("#midi-output-select"),
33
+ learnInputToggle: document.querySelector("#learn-input-toggle"),
34
+ autoSendToggle: document.querySelector("#auto-send-toggle"),
35
+ transposeToggle: document.querySelector("#transpose-toggle"),
36
+ forgetToggle: document.querySelector("#forget-toggle"),
37
+ keepLastInput: document.querySelector("#keep-last-input"),
38
+ decayModeSelect: document.querySelector("#decay-mode-select"),
39
+ createSessionButton: document.querySelector("#create-session-button"),
40
+ resetSessionButton: document.querySelector("#reset-session-button"),
41
+ applySettingsButton: document.querySelector("#apply-settings-button"),
42
+ connectMidiButton: document.querySelector("#connect-midi-button"),
43
+ refreshMidiButton: document.querySelector("#refresh-midi-button"),
44
+ sendPhraseButton: document.querySelector("#send-phrase-button"),
45
+ replayGeneratedButton: document.querySelector("#replay-generated-button"),
46
+ clearPhraseButton: document.querySelector("#clear-phrase-button"),
47
+ };
48
+
49
+ const state = {
50
+ midiAccess: null,
51
+ activeInputId: null,
52
+ sessionId: null,
53
+ sessionConfiguration: null,
54
+ lastCapturedPhrase: [],
55
+ lastGeneratedPhrase: null,
56
+ historyItems: [],
57
+ memoryItems: [],
58
+ activeActivityView: "history",
59
+ };
60
+
61
+ class BrowserSynth {
62
+ constructor() {
63
+ this.context = null;
64
+ this.master = null;
65
+ this.activeVoices = new Map();
66
+ }
67
+
68
+ async ensureContext() {
69
+ if (!this.context) {
70
+ this.context = new window.AudioContext();
71
+ this.master = new window.GainNode(this.context, { gain: 0.18 });
72
+ this.master.connect(this.context.destination);
73
+ }
74
+ if (this.context.state === "suspended") {
75
+ await this.context.resume();
76
+ }
77
+ }
78
+
79
+ key(note, channel) {
80
+ return `${channel}:${note}`;
81
+ }
82
+
83
+ midiToFrequency(note) {
84
+ return 440 * 2 ** ((note - 69) / 12);
85
+ }
86
+
87
+ async play(events) {
88
+ if (!events?.length) {
89
+ return;
90
+ }
91
+
92
+ await this.ensureContext();
93
+ let cursor = 0;
94
+ const startAt = this.context.currentTime + 0.05;
95
+
96
+ for (const event of events) {
97
+ cursor += event.delta_seconds;
98
+ const at = startAt + cursor;
99
+ const key = this.key(event.note, event.channel);
100
+
101
+ if (event.type === "note_on" && event.velocity > 0) {
102
+ const oscillator = new OscillatorNode(this.context, {
103
+ type: "triangle",
104
+ frequency: this.midiToFrequency(event.note),
105
+ });
106
+ const gain = new GainNode(this.context, { gain: 0.0001 });
107
+ oscillator.connect(gain).connect(this.master);
108
+ gain.gain.setValueAtTime(0.0001, at);
109
+ gain.gain.exponentialRampToValueAtTime(
110
+ Math.max(0.03, (event.velocity / 127) * 0.2),
111
+ at + 0.015,
112
+ );
113
+ oscillator.start(at);
114
+
115
+ const existing = this.activeVoices.get(key) || [];
116
+ existing.push({ oscillator, gain });
117
+ this.activeVoices.set(key, existing);
118
+ } else {
119
+ const voices = this.activeVoices.get(key);
120
+ if (!voices?.length) {
121
+ continue;
122
+ }
123
+ const voice = voices.shift();
124
+ voice.gain.gain.cancelScheduledValues(at);
125
+ voice.gain.gain.setValueAtTime(0.06, at);
126
+ voice.gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.08);
127
+ voice.oscillator.stop(at + 0.1);
128
+ if (!voices.length) {
129
+ this.activeVoices.delete(key);
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ class PhraseRecorder {
137
+ constructor(timeoutMs, onUpdate, onComplete) {
138
+ this.timeoutMs = timeoutMs;
139
+ this.onUpdate = onUpdate;
140
+ this.onComplete = onComplete;
141
+ this.reset();
142
+ }
143
+
144
+ reset() {
145
+ this.events = [];
146
+ this.pendingNotes = new Set();
147
+ this.lastTimestamp = null;
148
+ if (this.timer) {
149
+ window.clearTimeout(this.timer);
150
+ }
151
+ this.timer = null;
152
+ }
153
+
154
+ snapshot() {
155
+ return this.events.map((event) => ({ ...event }));
156
+ }
157
+
158
+ handleMessage(messageEvent) {
159
+ const [statusByte, note, rawVelocity = 0] = [...messageEvent.data];
160
+ const status = statusByte & 0xf0;
161
+ const channel = statusByte & 0x0f;
162
+ let type = null;
163
+ let velocity = rawVelocity;
164
+
165
+ if (status === 0x90 && velocity > 0) {
166
+ type = "note_on";
167
+ } else if (status === 0x80 || (status === 0x90 && velocity === 0)) {
168
+ type = "note_off";
169
+ velocity = 0;
170
+ } else {
171
+ return;
172
+ }
173
+
174
+ const timestamp =
175
+ typeof messageEvent.receivedTime === "number"
176
+ ? messageEvent.receivedTime
177
+ : window.performance.now();
178
+ const deltaSeconds =
179
+ this.lastTimestamp == null
180
+ ? 0
181
+ : Math.max(0, (timestamp - this.lastTimestamp) / 1000);
182
+ this.lastTimestamp = timestamp;
183
+
184
+ const event = {
185
+ type,
186
+ note,
187
+ velocity,
188
+ channel,
189
+ delta_seconds: roundNumber(deltaSeconds),
190
+ };
191
+
192
+ const key = `${channel}:${note}`;
193
+ if (type === "note_on") {
194
+ this.pendingNotes.add(key);
195
+ } else {
196
+ this.pendingNotes.delete(key);
197
+ }
198
+
199
+ this.events.push(event);
200
+ this.onUpdate?.(this.snapshot(), false);
201
+ this.scheduleCompletionCheck(timestamp);
202
+ }
203
+
204
+ scheduleCompletionCheck(nowTimestamp) {
205
+ if (this.timer) {
206
+ window.clearTimeout(this.timer);
207
+ }
208
+
209
+ if (!this.events.length || this.lastTimestamp == null) {
210
+ return;
211
+ }
212
+
213
+ if (this.pendingNotes.size) {
214
+ return;
215
+ }
216
+
217
+ const elapsed = nowTimestamp - this.lastTimestamp;
218
+ if (elapsed >= this.timeoutMs) {
219
+ this.completePhrase();
220
+ return;
221
+ }
222
+
223
+ this.timer = window.setTimeout(() => {
224
+ this.completePhrase();
225
+ }, this.timeoutMs - elapsed);
226
+ }
227
+
228
+ completePhrase() {
229
+ if (!this.events.length || this.pendingNotes.size || this.lastTimestamp == null) {
230
+ return;
231
+ }
232
+
233
+ const nowTimestamp = window.performance.now();
234
+ const elapsed = nowTimestamp - this.lastTimestamp;
235
+ if (elapsed < this.timeoutMs) {
236
+ this.scheduleCompletionCheck(nowTimestamp);
237
+ return;
238
+ }
239
+
240
+ const phrase = this.snapshot();
241
+ this.reset();
242
+ this.onComplete?.(phrase);
243
+ this.onUpdate?.(phrase, true);
244
+ }
245
+ }
246
+
247
+ const synth = new BrowserSynth();
248
+ const recorder = new PhraseRecorder(
249
+ PHRASE_TIMEOUT_MS,
250
+ (events, completed) => {
251
+ const notes = eventsToNotes(events);
252
+ renderCapturedStats(events, notes, completed);
253
+ },
254
+ async (phrase) => {
255
+ state.lastCapturedPhrase = phrase;
256
+ const notes = eventsToNotes(phrase);
257
+ renderCapturedStats(phrase, notes, true);
258
+ setPhraseMessage(
259
+ `Phrase complete: ${phrase.length} events / ${notes.length} notes captured.`,
260
+ );
261
+ if (elements.autoSendToggle.checked) {
262
+ await sendCurrentPhrase();
263
+ }
264
+ },
265
+ );
266
+
267
+ function roundNumber(value) {
268
+ return Math.round(value * 1_000_000) / 1_000_000;
269
+ }
270
+
271
+ function formatDurationSeconds(value) {
272
+ const duration = Number(value) || 0;
273
+ return duration >= 10 ? `${duration.toFixed(0)}s` : `${duration.toFixed(1)}s`;
274
+ }
275
+
276
+ function setPhraseMessage(message, danger = false) {
277
+ elements.messageBox.textContent = message;
278
+ elements.messageBox.style.color = danger ? "var(--danger)" : "var(--muted)";
279
+ }
280
+
281
+ function setSessionStatus(label) {
282
+ elements.sessionStatus.textContent = label;
283
+ }
284
+
285
+ function setMidiStatus(label) {
286
+ elements.midiStatus.textContent = label;
287
+ }
288
+
289
+ function setPhraseStatus(label) {
290
+ elements.phraseStatus.textContent = label;
291
+ }
292
+
293
+ function setSelectedInputName(label) {
294
+ elements.selectedInputName.textContent = label;
295
+ }
296
+
297
+ function setSelectedOutputName(label) {
298
+ elements.selectedOutputName.textContent = label;
299
+ }
300
+
301
+ function setLastMidiEvent(label) {
302
+ elements.lastMidiEvent.textContent = label;
303
+ }
304
+
305
+ function normalizedKeepLastInputs(value) {
306
+ const parsed = Number(value);
307
+ if (!Number.isFinite(parsed)) {
308
+ return 20;
309
+ }
310
+ return Math.min(500, Math.max(1, Math.round(parsed)));
311
+ }
312
+
313
+ function readSessionSettingsFromControls() {
314
+ return {
315
+ learn_input: elements.learnInputToggle.checked,
316
+ transposition: elements.transposeToggle.checked,
317
+ forget_past: elements.forgetToggle.checked,
318
+ keep_last_inputs: normalizedKeepLastInputs(elements.keepLastInput.value),
319
+ decay_mode: elements.decayModeSelect.value,
320
+ };
321
+ }
322
+
323
+ function describeSessionSettings(settings) {
324
+ const transposeLabel = settings.transposition ? "Transpose on" : "Transpose off";
325
+ const memoryLabel = settings.forget_past
326
+ ? `Keep last ${settings.keep_last_inputs} phrases`
327
+ : "Keep full memory";
328
+ const decayLabel = `Decay ${settings.decay_mode}`;
329
+ return [transposeLabel, memoryLabel, decayLabel];
330
+ }
331
+
332
+ function renderSessionSettingsSummary() {
333
+ const labels = describeSessionSettings(readSessionSettingsFromControls());
334
+ elements.settingsSummary.innerHTML = labels
335
+ .map((label) => `<span class="settings-chip">${label}</span>`)
336
+ .join("");
337
+ }
338
+
339
+ function updateKeepLastFieldState() {
340
+ const enabled = elements.forgetToggle.checked;
341
+ elements.keepLastInput.disabled = !enabled;
342
+ }
343
+
344
+ function syncSettingsControls(configuration) {
345
+ if (!configuration) {
346
+ return;
347
+ }
348
+
349
+ state.sessionConfiguration = configuration;
350
+ elements.learnInputToggle.checked = configuration.learn_input;
351
+ elements.transposeToggle.checked = configuration.transposition;
352
+ elements.forgetToggle.checked = configuration.forget_past;
353
+ elements.keepLastInput.value = String(configuration.keep_last_inputs);
354
+ elements.decayModeSelect.value = configuration.decay_mode;
355
+ updateKeepLastFieldState();
356
+ renderSessionSettingsSummary();
357
+ }
358
+
359
+ function updateSessionActionState() {
360
+ elements.applySettingsButton.disabled = !state.sessionId;
361
+ }
362
+
363
+ function setActivityView(view) {
364
+ state.activeActivityView = view;
365
+ const showHistory = view === "history";
366
+ elements.historyTab.classList.toggle("is-active", showHistory);
367
+ elements.historyTab.setAttribute("aria-selected", String(showHistory));
368
+ elements.historyPanel.hidden = !showHistory;
369
+ elements.memoryTab.classList.toggle("is-active", !showHistory);
370
+ elements.memoryTab.setAttribute("aria-selected", String(!showHistory));
371
+ elements.memoryPanel.hidden = showHistory;
372
+ }
373
+
374
+ function previewPhrasePayload(payload, kind, message) {
375
+ if (kind === "generated") {
376
+ state.lastGeneratedPhrase = payload;
377
+ renderGeneratedStats(payload);
378
+ } else {
379
+ state.lastCapturedPhrase = payload.events;
380
+ renderCapturedStats(payload.events, payload.notes, true);
381
+ }
382
+ setPhraseMessage(message);
383
+ }
384
+
385
+ function renderCapturedStats(events, notes, completed) {
386
+ elements.capturedEventCount.textContent = String(events.length);
387
+ elements.capturedNoteCount.textContent = String(notes.length);
388
+ setPhraseStatus(completed ? "Phrase ready" : "Listening");
389
+ drawPianoRoll(elements.inputRoll, notes, "#6dd3ce", "Input phrase");
390
+ }
391
+
392
+ function renderGeneratedStats(payload) {
393
+ elements.generatedEventCount.textContent = String(payload?.event_count || 0);
394
+ elements.generatedNoteCount.textContent = String(payload?.note_count || 0);
395
+ drawPianoRoll(
396
+ elements.outputRoll,
397
+ payload?.notes || [],
398
+ "#f4a261",
399
+ "Generated continuation",
400
+ );
401
+ }
402
+
403
+ function createHistoryMarkup(items) {
404
+ if (!items.length) {
405
+ return `<p class="muted">No phrases logged yet for this session.</p>`;
406
+ }
407
+
408
+ return items
409
+ .map(
410
+ (item, index) => `
411
+ <button class="history-item" data-history-index="${index}" type="button">
412
+ <span class="history-kind">${item.kind}</span>
413
+ <span class="history-meta">
414
+ <strong>${item.note_count} notes / ${item.event_count} events</strong>
415
+ <span>${item.created_at}</span>
416
+ </span>
417
+ <span class="history-index">open</span>
418
+ </button>
419
+ `,
420
+ )
421
+ .join("");
422
+ }
423
+
424
+ function attachHistoryEvents() {
425
+ elements.historyList.querySelectorAll("[data-history-index]").forEach((node) => {
426
+ node.addEventListener("click", () => {
427
+ const item = state.historyItems[Number(node.dataset.historyIndex)];
428
+ if (!item) {
429
+ return;
430
+ }
431
+
432
+ previewPhrasePayload(item.payload, item.kind, `Previewing ${item.kind} phrase from ${item.created_at}.`);
433
+ });
434
+ });
435
+ }
436
+
437
+ function renderHistory(items) {
438
+ state.historyItems = items;
439
+ elements.historyList.innerHTML = createHistoryMarkup(items);
440
+ attachHistoryEvents();
441
+ }
442
+
443
+ function createMemorySummaryMarkup(memory) {
444
+ if (!memory) {
445
+ return `<span class="settings-chip">No active memory yet</span>`;
446
+ }
447
+
448
+ const chips = [
449
+ `${memory.summary.active_phrase_count} active sequences`,
450
+ `${memory.summary.live_phrase_count} live`,
451
+ ];
452
+ if (memory.summary.seeded_phrase_count) {
453
+ chips.push(`${memory.summary.seeded_phrase_count} seed`);
454
+ }
455
+ chips.push(memory.configuration.transposition ? "Transpose on" : "Transpose off");
456
+ chips.push(
457
+ memory.configuration.forget_past
458
+ ? `Keep last ${memory.configuration.keep_last_inputs}`
459
+ : "Keep full memory",
460
+ );
461
+ chips.push(`Decay ${memory.configuration.decay_mode}`);
462
+
463
+ return chips.map((label) => `<span class="settings-chip">${label}</span>`).join("");
464
+ }
465
+
466
+ function createMemoryHint(memory) {
467
+ if (!memory) {
468
+ return "Create a session and play a phrase to inspect the active Continuator memory.";
469
+ }
470
+ if (!memory.summary.active_phrase_count) {
471
+ return memory.configuration.transposition
472
+ ? "No active sequences yet. When transpose is on, each learned phrase can appear as several active transposed variants."
473
+ : "No active sequences yet. Play a phrase to start filling the Continuator memory.";
474
+ }
475
+ return memory.configuration.transposition
476
+ ? "The ribbon reads oldest to newest. The list below starts with the newest active sequence, and transposed variants appear separately when transpose is enabled."
477
+ : "The ribbon reads oldest to newest. The list below starts with the newest active sequence. Click any item to preview it in the main piano roll.";
478
+ }
479
+
480
+ function createMemoryRibbonMarkup(items) {
481
+ if (!items.length) {
482
+ return "";
483
+ }
484
+
485
+ return items
486
+ .map((item, index) => {
487
+ const opacity = roundNumber(0.3 + ((index + 1) / items.length) * 0.6);
488
+ return `
489
+ <button
490
+ class="memory-ribbon-cell ${item.source}"
491
+ data-memory-index="${index}"
492
+ type="button"
493
+ title="Slot ${item.slot}: ${item.note_count} notes"
494
+ style="opacity: ${opacity};"
495
+ ></button>
496
+ `;
497
+ })
498
+ .join("");
499
+ }
500
+
501
+ function createMemoryMarkup(items) {
502
+ if (!items.length) {
503
+ return `<p class="muted">No active memory to show yet.</p>`;
504
+ }
505
+
506
+ return items
507
+ .slice()
508
+ .reverse()
509
+ .map((item) => {
510
+ const itemLabel = item.source === "seed" ? "Seed" : "Live";
511
+ return `
512
+ <button class="history-item memory-item" data-memory-index="${item.slot - 1}" type="button">
513
+ <span class="history-kind">${itemLabel} #${item.slot}</span>
514
+ <span class="history-meta">
515
+ <strong>${item.note_count} notes / ${formatDurationSeconds(item.duration_seconds)}</strong>
516
+ <span>Active slot ${item.slot} in current engine memory</span>
517
+ </span>
518
+ <span class="history-index">preview</span>
519
+ </button>
520
+ `;
521
+ })
522
+ .join("");
523
+ }
524
+
525
+ function attachMemoryEvents() {
526
+ const previewMemoryIndex = (rawIndex) => {
527
+ const item = state.memoryItems[Number(rawIndex)];
528
+ if (!item) {
529
+ return;
530
+ }
531
+ previewPhrasePayload(
532
+ item.payload,
533
+ "input",
534
+ `Previewing ${item.source} memory slot ${item.slot}.`,
535
+ );
536
+ };
537
+
538
+ elements.memoryList.querySelectorAll("[data-memory-index]").forEach((node) => {
539
+ node.addEventListener("click", () => {
540
+ previewMemoryIndex(node.dataset.memoryIndex);
541
+ });
542
+ });
543
+
544
+ elements.memoryRibbon.querySelectorAll("[data-memory-index]").forEach((node) => {
545
+ node.addEventListener("click", () => {
546
+ previewMemoryIndex(node.dataset.memoryIndex);
547
+ });
548
+ });
549
+ }
550
+
551
+ function renderMemory(memory) {
552
+ state.memoryItems = memory?.items || [];
553
+ elements.memorySummary.innerHTML = createMemorySummaryMarkup(memory);
554
+ elements.memoryHint.textContent = createMemoryHint(memory);
555
+ elements.memoryRibbon.innerHTML = createMemoryRibbonMarkup(state.memoryItems);
556
+ elements.memoryList.innerHTML = createMemoryMarkup(state.memoryItems);
557
+ attachMemoryEvents();
558
+ }
559
+
560
+ function drawPianoRoll(canvas, notes, accent, emptyLabel) {
561
+ const rect = canvas.getBoundingClientRect();
562
+ const dpr = window.devicePixelRatio || 1;
563
+ const width = Math.max(320, Math.floor(rect.width || 640));
564
+ const height = Math.max(180, Math.floor(rect.height || 244));
565
+ canvas.width = width * dpr;
566
+ canvas.height = height * dpr;
567
+
568
+ const ctx = canvas.getContext("2d");
569
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
570
+ ctx.clearRect(0, 0, width, height);
571
+
572
+ const background = ctx.createLinearGradient(0, 0, 0, height);
573
+ background.addColorStop(0, "rgba(255, 255, 255, 0.06)");
574
+ background.addColorStop(1, "rgba(255, 255, 255, 0.015)");
575
+ ctx.fillStyle = background;
576
+ ctx.fillRect(0, 0, width, height);
577
+
578
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.06)";
579
+ ctx.lineWidth = 1;
580
+ for (let index = 1; index < 8; index += 1) {
581
+ const x = (index / 8) * width;
582
+ ctx.beginPath();
583
+ ctx.moveTo(x, 0);
584
+ ctx.lineTo(x, height);
585
+ ctx.stroke();
586
+ }
587
+ for (let index = 1; index < 6; index += 1) {
588
+ const y = (index / 6) * height;
589
+ ctx.beginPath();
590
+ ctx.moveTo(0, y);
591
+ ctx.lineTo(width, y);
592
+ ctx.stroke();
593
+ }
594
+
595
+ if (!notes?.length) {
596
+ ctx.fillStyle = "rgba(236, 244, 239, 0.45)";
597
+ ctx.font = '600 14px "Avenir Next", "Segoe UI Variable", sans-serif';
598
+ ctx.fillText(emptyLabel, 20, height / 2);
599
+ return;
600
+ }
601
+
602
+ const minPitch = Math.max(24, Math.min(...notes.map((note) => note.pitch)) - 2);
603
+ const maxPitch = Math.min(108, Math.max(...notes.map((note) => note.pitch)) + 2);
604
+ const totalDuration = Math.max(
605
+ 2,
606
+ ...notes.map((note) => note.end_seconds || note.start_seconds + note.duration_seconds),
607
+ );
608
+ const pitchRange = Math.max(1, maxPitch - minPitch + 1);
609
+
610
+ ctx.fillStyle = accent;
611
+ ctx.shadowBlur = 18;
612
+ ctx.shadowColor = accent;
613
+
614
+ for (const note of notes) {
615
+ const x = (note.start_seconds / totalDuration) * width;
616
+ const noteWidth = Math.max(
617
+ 8,
618
+ (Math.max(0.05, note.duration_seconds) / totalDuration) * width,
619
+ );
620
+ const y =
621
+ height - ((note.pitch - minPitch + 1) / pitchRange) * (height - 24) - 10;
622
+ const noteHeight = Math.max(10, (height - 34) / pitchRange + 4);
623
+ roundRect(ctx, x + 2, y, noteWidth, noteHeight, 8, true);
624
+ }
625
+
626
+ ctx.shadowBlur = 0;
627
+ }
628
+
629
+ function roundRect(ctx, x, y, width, height, radius, fill) {
630
+ const r = Math.min(radius, width / 2, height / 2);
631
+ ctx.beginPath();
632
+ ctx.moveTo(x + r, y);
633
+ ctx.arcTo(x + width, y, x + width, y + height, r);
634
+ ctx.arcTo(x + width, y + height, x, y + height, r);
635
+ ctx.arcTo(x, y + height, x, y, r);
636
+ ctx.arcTo(x, y, x + width, y, r);
637
+ ctx.closePath();
638
+ if (fill) {
639
+ ctx.fill();
640
+ }
641
+ }
642
+
643
+ function eventsToNotes(events) {
644
+ const notes = [];
645
+ const pending = new Map();
646
+ let currentTime = 0;
647
+
648
+ for (const event of events) {
649
+ currentTime += event.delta_seconds;
650
+ const key = `${event.channel}:${event.note}`;
651
+
652
+ if (event.type === "note_on" && event.velocity > 0) {
653
+ const stack = pending.get(key) || [];
654
+ stack.push({
655
+ note: event.note,
656
+ velocity: event.velocity,
657
+ start_seconds: currentTime,
658
+ });
659
+ pending.set(key, stack);
660
+ continue;
661
+ }
662
+
663
+ const stack = pending.get(key);
664
+ if (!stack?.length) {
665
+ continue;
666
+ }
667
+
668
+ const noteOn = stack.shift();
669
+ notes.push({
670
+ pitch: noteOn.note,
671
+ velocity: noteOn.velocity,
672
+ start_seconds: roundNumber(noteOn.start_seconds),
673
+ duration_seconds: roundNumber(Math.max(0, currentTime - noteOn.start_seconds)),
674
+ end_seconds: roundNumber(currentTime),
675
+ });
676
+ if (!stack.length) {
677
+ pending.delete(key);
678
+ }
679
+ }
680
+
681
+ notes.sort(
682
+ (left, right) =>
683
+ left.start_seconds - right.start_seconds || left.pitch - right.pitch,
684
+ );
685
+ return notes;
686
+ }
687
+
688
+ async function createSession() {
689
+ const settings = readSessionSettingsFromControls();
690
+ const response = await fetch("/api/session", {
691
+ method: "POST",
692
+ headers: { "Content-Type": "application/json" },
693
+ body: JSON.stringify(settings),
694
+ });
695
+
696
+ if (!response.ok) {
697
+ throw new Error(await response.text());
698
+ }
699
+
700
+ const payload = await response.json();
701
+ state.sessionId = payload.session_id;
702
+ state.sessionConfiguration = payload.configuration;
703
+ elements.sessionId.textContent = payload.session_id;
704
+ syncSettingsControls(payload.configuration);
705
+ updateSessionActionState();
706
+ setSessionStatus("Ready");
707
+ setPhraseMessage(
708
+ `Session created. ${describeSessionSettings(payload.configuration).join(" · ")}.`,
709
+ );
710
+ await refreshSessionActivity();
711
+ }
712
+
713
+ async function ensureSession() {
714
+ if (!state.sessionId) {
715
+ await createSession();
716
+ }
717
+ }
718
+
719
+ async function resetSession() {
720
+ if (!state.sessionId) {
721
+ setPhraseMessage("Create a session before resetting it.", true);
722
+ return;
723
+ }
724
+
725
+ const response = await fetch(`/api/sessions/${state.sessionId}/reset`, {
726
+ method: "POST",
727
+ });
728
+ if (!response.ok) {
729
+ throw new Error(await response.text());
730
+ }
731
+ const payload = await response.json();
732
+
733
+ state.lastGeneratedPhrase = null;
734
+ renderGeneratedStats(null);
735
+ syncSettingsControls(payload.configuration);
736
+ setPhraseMessage("Session memory cleared and the current settings were preserved.");
737
+ await refreshMemory();
738
+ }
739
+
740
+ async function applyCurrentSessionSettings() {
741
+ if (!state.sessionId) {
742
+ setPhraseMessage("Create a session before applying settings.", true);
743
+ return;
744
+ }
745
+
746
+ const settings = readSessionSettingsFromControls();
747
+ const response = await fetch(`/api/sessions/${state.sessionId}/settings`, {
748
+ method: "PATCH",
749
+ headers: { "Content-Type": "application/json" },
750
+ body: JSON.stringify(settings),
751
+ });
752
+
753
+ if (!response.ok) {
754
+ throw new Error(await response.text());
755
+ }
756
+
757
+ const payload = await response.json();
758
+ syncSettingsControls(payload.configuration);
759
+ setPhraseMessage(
760
+ `Session settings updated. ${describeSessionSettings(payload.configuration).join(" · ")}.`,
761
+ );
762
+ await refreshMemory();
763
+ }
764
+
765
+ async function refreshHistory() {
766
+ if (!state.sessionId) {
767
+ return;
768
+ }
769
+
770
+ const response = await fetch(
771
+ `/api/sessions/${state.sessionId}/history?limit=10`,
772
+ );
773
+ if (!response.ok) {
774
+ throw new Error(await response.text());
775
+ }
776
+
777
+ const payload = await response.json();
778
+ renderHistory(payload.items);
779
+ }
780
+
781
+ async function refreshMemory() {
782
+ if (!state.sessionId) {
783
+ return;
784
+ }
785
+
786
+ const response = await fetch(`/api/sessions/${state.sessionId}/memory`);
787
+ if (!response.ok) {
788
+ throw new Error(await response.text());
789
+ }
790
+
791
+ const payload = await response.json();
792
+ renderMemory(payload);
793
+ }
794
+
795
+ async function refreshSessionActivity() {
796
+ if (!state.sessionId) {
797
+ return;
798
+ }
799
+
800
+ await Promise.all([refreshHistory(), refreshMemory()]);
801
+ }
802
+
803
+ async function sendCurrentPhrase() {
804
+ if (!state.lastCapturedPhrase.length) {
805
+ setPhraseMessage("No completed phrase is ready yet.", true);
806
+ return;
807
+ }
808
+
809
+ await ensureSession();
810
+ setPhraseStatus("Sending");
811
+
812
+ const response = await fetch("/api/continue", {
813
+ method: "POST",
814
+ headers: { "Content-Type": "application/json" },
815
+ body: JSON.stringify({
816
+ session_id: state.sessionId,
817
+ phrase: state.lastCapturedPhrase,
818
+ learn_input: elements.learnInputToggle.checked,
819
+ }),
820
+ });
821
+
822
+ if (!response.ok) {
823
+ const errorText = await response.text();
824
+ throw new Error(errorText);
825
+ }
826
+
827
+ const payload = await response.json();
828
+ state.lastGeneratedPhrase = payload.generated_phrase;
829
+ renderGeneratedStats(payload.generated_phrase);
830
+ setPhraseStatus(payload.generated_phrase.note_count ? "Generated" : "Primed");
831
+ setPhraseMessage(
832
+ payload.status_message ||
833
+ `Continuation generated: ${payload.generated_phrase.note_count} notes returned.`,
834
+ );
835
+ await refreshSessionActivity();
836
+ if (payload.generated_phrase.event_count > 0) {
837
+ await playPayload(payload.generated_phrase);
838
+ }
839
+ }
840
+
841
+ async function checkServer() {
842
+ const response = await fetch("/health");
843
+ if (!response.ok) {
844
+ throw new Error("Server health check failed.");
845
+ }
846
+ const payload = await response.json();
847
+ elements.serverStatus.textContent = payload.ok
848
+ ? payload.seeded
849
+ ? "Healthy / seeded"
850
+ : "Healthy / empty memory"
851
+ : "Unavailable";
852
+ }
853
+
854
+ function selectedMidiOutput() {
855
+ if (!state.midiAccess) {
856
+ return null;
857
+ }
858
+ return state.midiAccess.outputs.get(elements.midiOutputSelect.value) || null;
859
+ }
860
+
861
+ async function playPayload(payload) {
862
+ if (!payload?.events?.length) {
863
+ return;
864
+ }
865
+
866
+ if (elements.midiOutputSelect.value === BROWSER_SYNTH_ID) {
867
+ await synth.play(payload.events);
868
+ return;
869
+ }
870
+
871
+ const output = selectedMidiOutput();
872
+ if (!output) {
873
+ await synth.play(payload.events);
874
+ return;
875
+ }
876
+
877
+ await output.open();
878
+ let cursorMs = 0;
879
+ const startAt = window.performance.now() + 80;
880
+ for (const event of payload.events) {
881
+ cursorMs += event.delta_seconds * 1000;
882
+ const status =
883
+ event.type === "note_on" && event.velocity > 0
884
+ ? 0x90 | (event.channel & 0x0f)
885
+ : 0x80 | (event.channel & 0x0f);
886
+ output.send([status, event.note, event.velocity], startAt + cursorMs);
887
+ }
888
+ }
889
+
890
+ async function populateMidiSelectors() {
891
+ if (!state.midiAccess) {
892
+ return;
893
+ }
894
+
895
+ const inputs = [...state.midiAccess.inputs.values()];
896
+ const outputs = [...state.midiAccess.outputs.values()];
897
+ const previousInputId = elements.midiInputSelect.value;
898
+ const previousOutputId = elements.midiOutputSelect.value;
899
+
900
+ elements.midiInputSelect.disabled = false;
901
+ elements.midiInputSelect.innerHTML = inputs.length
902
+ ? inputs
903
+ .map(
904
+ (input) =>
905
+ `<option value="${input.id}">${input.name || input.id}</option>`,
906
+ )
907
+ .join("")
908
+ : `<option value="">No MIDI inputs found</option>`;
909
+
910
+ elements.midiOutputSelect.disabled = false;
911
+ const outputOptions = [
912
+ `<option value="${BROWSER_SYNTH_ID}">Browser Synth</option>`,
913
+ ...outputs.map(
914
+ (output) =>
915
+ `<option value="${output.id}">${output.name || output.id}</option>`,
916
+ ),
917
+ ];
918
+ elements.midiOutputSelect.innerHTML = outputOptions.join("");
919
+ elements.midiOutputSelect.value =
920
+ previousOutputId &&
921
+ (previousOutputId === BROWSER_SYNTH_ID ||
922
+ state.midiAccess.outputs.has(previousOutputId))
923
+ ? previousOutputId
924
+ : BROWSER_SYNTH_ID;
925
+ updateSelectedOutput();
926
+
927
+ if (inputs.length) {
928
+ const inputId = state.midiAccess.inputs.has(previousInputId)
929
+ ? previousInputId
930
+ : inputs[0].id;
931
+ await attachInput(inputId);
932
+ } else {
933
+ detachCurrentInput();
934
+ setSelectedInputName("No MIDI input found");
935
+ setMidiStatus("No inputs");
936
+ setLastMidiEvent("None yet");
937
+ }
938
+ }
939
+
940
+ function detachCurrentInput() {
941
+ if (!state.midiAccess || !state.activeInputId) {
942
+ return;
943
+ }
944
+ const current = state.midiAccess.inputs.get(state.activeInputId);
945
+ if (current) {
946
+ current.onmidimessage = null;
947
+ void current.close().catch(() => {});
948
+ }
949
+ state.activeInputId = null;
950
+ }
951
+
952
+ async function attachInput(inputId) {
953
+ detachCurrentInput();
954
+
955
+ if (!state.midiAccess || !inputId) {
956
+ state.activeInputId = null;
957
+ setSelectedInputName("No MIDI input selected");
958
+ return;
959
+ }
960
+
961
+ const input = state.midiAccess.inputs.get(inputId);
962
+ if (!input) {
963
+ setSelectedInputName("Selected input is unavailable");
964
+ return;
965
+ }
966
+
967
+ await input.open();
968
+ input.onmidimessage = (messageEvent) => {
969
+ recorder.handleMessage(messageEvent);
970
+ const [statusByte, note, velocity = 0] = [...messageEvent.data];
971
+ const status = statusByte & 0xf0;
972
+ const type =
973
+ status === 0x90 && velocity > 0
974
+ ? "note_on"
975
+ : status === 0x80 || (status === 0x90 && velocity === 0)
976
+ ? "note_off"
977
+ : "message";
978
+ setLastMidiEvent(`${type} ${note} v${velocity}`);
979
+ setPhraseMessage(
980
+ `Receiving MIDI from ${input.name || input.id}. Waiting for phrase end…`,
981
+ );
982
+ };
983
+
984
+ state.activeInputId = inputId;
985
+ elements.midiInputSelect.value = inputId;
986
+ setSelectedInputName(input.name || input.id);
987
+ setMidiStatus(`Listening on ${input.name || input.id}`);
988
+ }
989
+
990
+ async function connectMidi() {
991
+ if (!navigator.requestMIDIAccess) {
992
+ throw new Error("This browser does not support the Web MIDI API.");
993
+ }
994
+
995
+ state.midiAccess = await navigator.requestMIDIAccess({ sysex: false });
996
+ state.midiAccess.onstatechange = () => {
997
+ void populateMidiSelectors();
998
+ };
999
+ await populateMidiSelectors();
1000
+ if (!state.activeInputId) {
1001
+ setMidiStatus("Connected / choose input");
1002
+ }
1003
+ setPhraseStatus("Listening");
1004
+ }
1005
+
1006
+ function updateSelectedOutput() {
1007
+ const outputId = elements.midiOutputSelect.value;
1008
+ if (outputId === BROWSER_SYNTH_ID) {
1009
+ setSelectedOutputName("Browser Synth");
1010
+ return;
1011
+ }
1012
+
1013
+ const output = selectedMidiOutput();
1014
+ setSelectedOutputName(output ? output.name || output.id : "Unavailable output");
1015
+ }
1016
+
1017
+ function clearPhrases() {
1018
+ state.lastCapturedPhrase = [];
1019
+ state.lastGeneratedPhrase = null;
1020
+ recorder.reset();
1021
+ renderCapturedStats([], [], false);
1022
+ renderGeneratedStats(null);
1023
+ setPhraseStatus("Waiting for MIDI");
1024
+ setPhraseMessage("Cleared the local phrase buffers.");
1025
+ }
1026
+
1027
+ function bindEvents() {
1028
+ elements.viewTabs.forEach((node) => {
1029
+ node.addEventListener("click", () => {
1030
+ setActivityView(node.dataset.viewTab);
1031
+ });
1032
+ });
1033
+
1034
+ elements.createSessionButton.addEventListener("click", async () => {
1035
+ try {
1036
+ await createSession();
1037
+ } catch (error) {
1038
+ setPhraseMessage(error.message, true);
1039
+ }
1040
+ });
1041
+
1042
+ elements.resetSessionButton.addEventListener("click", async () => {
1043
+ try {
1044
+ await resetSession();
1045
+ } catch (error) {
1046
+ setPhraseMessage(error.message, true);
1047
+ }
1048
+ });
1049
+
1050
+ elements.connectMidiButton.addEventListener("click", async () => {
1051
+ try {
1052
+ await connectMidi();
1053
+ } catch (error) {
1054
+ setPhraseMessage(error.message, true);
1055
+ setMidiStatus("Unavailable");
1056
+ }
1057
+ });
1058
+
1059
+ elements.refreshMidiButton.addEventListener("click", async () => {
1060
+ try {
1061
+ if (!state.midiAccess) {
1062
+ await connectMidi();
1063
+ return;
1064
+ }
1065
+ await populateMidiSelectors();
1066
+ setPhraseMessage("MIDI ports refreshed.");
1067
+ } catch (error) {
1068
+ setPhraseMessage(error.message, true);
1069
+ }
1070
+ });
1071
+
1072
+ elements.sendPhraseButton.addEventListener("click", async () => {
1073
+ try {
1074
+ await sendCurrentPhrase();
1075
+ } catch (error) {
1076
+ setPhraseMessage(error.message, true);
1077
+ setPhraseStatus("Error");
1078
+ }
1079
+ });
1080
+
1081
+ elements.replayGeneratedButton.addEventListener("click", async () => {
1082
+ if (!state.lastGeneratedPhrase) {
1083
+ setPhraseMessage("No generated phrase is available yet.", true);
1084
+ return;
1085
+ }
1086
+ try {
1087
+ await playPayload(state.lastGeneratedPhrase);
1088
+ setPhraseMessage("Replaying the latest generated phrase.");
1089
+ } catch (error) {
1090
+ setPhraseMessage(error.message, true);
1091
+ }
1092
+ });
1093
+
1094
+ elements.clearPhraseButton.addEventListener("click", () => {
1095
+ clearPhrases();
1096
+ });
1097
+
1098
+ elements.applySettingsButton.addEventListener("click", async () => {
1099
+ try {
1100
+ await applyCurrentSessionSettings();
1101
+ } catch (error) {
1102
+ setPhraseMessage(error.message, true);
1103
+ }
1104
+ });
1105
+
1106
+ elements.midiInputSelect.addEventListener("change", async (event) => {
1107
+ try {
1108
+ await attachInput(event.target.value);
1109
+ setPhraseMessage(`MIDI input changed to ${elements.selectedInputName.textContent}.`);
1110
+ } catch (error) {
1111
+ setPhraseMessage(error.message, true);
1112
+ }
1113
+ });
1114
+
1115
+ elements.midiOutputSelect.addEventListener("change", async () => {
1116
+ updateSelectedOutput();
1117
+ const output = selectedMidiOutput();
1118
+ if (output) {
1119
+ try {
1120
+ await output.open();
1121
+ } catch (error) {
1122
+ setPhraseMessage(error.message, true);
1123
+ return;
1124
+ }
1125
+ }
1126
+ setPhraseMessage(`Playback output set to ${elements.selectedOutputName.textContent}.`);
1127
+ });
1128
+
1129
+ elements.learnInputToggle.addEventListener("change", () => {
1130
+ renderSessionSettingsSummary();
1131
+ });
1132
+
1133
+ elements.transposeToggle.addEventListener("change", () => {
1134
+ renderSessionSettingsSummary();
1135
+ });
1136
+
1137
+ elements.forgetToggle.addEventListener("change", () => {
1138
+ updateKeepLastFieldState();
1139
+ renderSessionSettingsSummary();
1140
+ });
1141
+
1142
+ elements.keepLastInput.addEventListener("change", () => {
1143
+ elements.keepLastInput.value = String(
1144
+ normalizedKeepLastInputs(elements.keepLastInput.value),
1145
+ );
1146
+ renderSessionSettingsSummary();
1147
+ });
1148
+
1149
+ elements.decayModeSelect.addEventListener("change", () => {
1150
+ renderSessionSettingsSummary();
1151
+ });
1152
+
1153
+ window.addEventListener("resize", () => {
1154
+ drawPianoRoll(
1155
+ elements.inputRoll,
1156
+ eventsToNotes(state.lastCapturedPhrase),
1157
+ "#6dd3ce",
1158
+ "Input phrase",
1159
+ );
1160
+ renderGeneratedStats(state.lastGeneratedPhrase);
1161
+ });
1162
+ }
1163
+
1164
+ async function initialize() {
1165
+ bindEvents();
1166
+ clearPhrases();
1167
+ renderMemory(null);
1168
+ setActivityView("history");
1169
+ setSelectedInputName("No MIDI input selected");
1170
+ setSelectedOutputName("Browser Synth");
1171
+ setLastMidiEvent("None yet");
1172
+ updateKeepLastFieldState();
1173
+ renderSessionSettingsSummary();
1174
+ updateSessionActionState();
1175
+ try {
1176
+ await checkServer();
1177
+ } catch (error) {
1178
+ elements.serverStatus.textContent = "Offline";
1179
+ setPhraseMessage(error.message, true);
1180
+ }
1181
+ }
1182
+
1183
+ initialize();
frontend/index.html ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Continuator Web Pilot</title>
7
+ <link rel="stylesheet" href="/assets/styles.css" />
8
+ </head>
9
+ <body>
10
+ <div class="page-aura"></div>
11
+ <div class="page-grid"></div>
12
+ <header class="hero">
13
+ <div class="hero-copy">
14
+ <p class="eyebrow">Web MIDI + FastAPI + Continuator</p>
15
+ <h1>Continuator Web Pilot</h1>
16
+ <p class="lede">
17
+ Capture a phrase from a MIDI keyboard in the browser, send it to the
18
+ Python Continuator, and play the continuation back through a MIDI port
19
+ or a built-in browser synth.
20
+ </p>
21
+ </div>
22
+ <div class="hero-chip">
23
+ <span>Session-isolated</span>
24
+ <span>SQLite logged</span>
25
+ <span>Docker ready</span>
26
+ </div>
27
+ </header>
28
+
29
+ <main class="layout">
30
+ <section class="column">
31
+ <article class="card">
32
+ <div class="card-head">
33
+ <h2>Session</h2>
34
+ <span class="status-pill" id="session-status">No session</span>
35
+ </div>
36
+ <p class="muted">
37
+ Each session owns its own in-memory Continuator engine so users do
38
+ not share style memory.
39
+ </p>
40
+ <div class="button-row">
41
+ <button id="create-session-button">Create Session</button>
42
+ <button id="reset-session-button" class="ghost">Reset Memory</button>
43
+ </div>
44
+ <div class="settings-compact">
45
+ <div class="settings-label-row">
46
+ <span class="small-copy">Continuator settings</span>
47
+ <span class="small-copy">Compact summary</span>
48
+ </div>
49
+ <div id="settings-summary" class="settings-summary"></div>
50
+ </div>
51
+ <details class="settings-panel">
52
+ <summary>Advanced Continuator Settings</summary>
53
+ <p class="muted settings-copy">
54
+ Keep the performance controls on the surface, and put the
55
+ lower-frequency model and memory settings here.
56
+ </p>
57
+ <div class="settings-grid">
58
+ <label class="toggle settings-toggle">
59
+ <input id="transpose-toggle" type="checkbox" />
60
+ <span>Transpose incoming phrases before learning</span>
61
+ </label>
62
+ <div class="field">
63
+ <label for="decay-mode-select">Decay mode</label>
64
+ <select id="decay-mode-select">
65
+ <option value="full">Full</option>
66
+ <option value="late">Late</option>
67
+ <option value="middle">Middle</option>
68
+ <option value="early">Early</option>
69
+ </select>
70
+ </div>
71
+ <label class="toggle settings-toggle">
72
+ <input id="forget-toggle" type="checkbox" />
73
+ <span>Forget old phrases and keep a rolling memory</span>
74
+ </label>
75
+ <div class="field">
76
+ <label for="keep-last-input">Keep only N last inputs</label>
77
+ <input id="keep-last-input" type="number" min="1" max="500" step="1" value="20" />
78
+ <p class="field-hint">
79
+ This cap is used when forgetting is enabled.
80
+ </p>
81
+ </div>
82
+ </div>
83
+ <div class="button-row">
84
+ <button id="apply-settings-button" class="ghost">
85
+ Apply to Current Session
86
+ </button>
87
+ </div>
88
+ <p class="small-copy settings-copy">
89
+ New sessions use these settings automatically. Use Apply to update
90
+ the current running session without clearing its memory.
91
+ </p>
92
+ </details>
93
+ <dl class="info-grid">
94
+ <div>
95
+ <dt>Session ID</dt>
96
+ <dd id="session-id">Not created yet</dd>
97
+ </div>
98
+ <div>
99
+ <dt>Server</dt>
100
+ <dd id="server-status">Checking…</dd>
101
+ </div>
102
+ </dl>
103
+ </article>
104
+
105
+ <article class="card accent-card">
106
+ <div class="card-head">
107
+ <h2>MIDI I/O</h2>
108
+ <span class="status-pill" id="midi-status">Disconnected</span>
109
+ </div>
110
+ <p class="muted">
111
+ Connect browser MIDI, choose an input and output port explicitly,
112
+ then play. A phrase is considered finished 1 second after the final
113
+ note release.
114
+ </p>
115
+ <div class="button-row">
116
+ <button id="connect-midi-button">Connect MIDI</button>
117
+ <button id="refresh-midi-button" class="ghost">Refresh Ports</button>
118
+ <label class="toggle">
119
+ <input id="auto-send-toggle" type="checkbox" checked />
120
+ <span>Auto-send phrase</span>
121
+ </label>
122
+ </div>
123
+ <div class="field">
124
+ <label for="midi-input-select">MIDI Input</label>
125
+ <select id="midi-input-select" disabled>
126
+ <option>Connect MIDI first</option>
127
+ </select>
128
+ </div>
129
+ <div class="field">
130
+ <label for="midi-output-select">Playback Output</label>
131
+ <select id="midi-output-select" disabled>
132
+ <option>Browser Synth</option>
133
+ </select>
134
+ </div>
135
+ <label class="toggle">
136
+ <input id="learn-input-toggle" type="checkbox" checked />
137
+ <span>Learn the played phrase before generating</span>
138
+ </label>
139
+ <dl class="info-grid">
140
+ <div>
141
+ <dt>Selected Input</dt>
142
+ <dd id="selected-input-name">None</dd>
143
+ </div>
144
+ <div>
145
+ <dt>Selected Output</dt>
146
+ <dd id="selected-output-name">Browser Synth</dd>
147
+ </div>
148
+ <div>
149
+ <dt>Last MIDI Event</dt>
150
+ <dd id="last-midi-event">None yet</dd>
151
+ </div>
152
+ <div>
153
+ <dt>Capture Rule</dt>
154
+ <dd>1 second after final note-off</dd>
155
+ </div>
156
+ </dl>
157
+ </article>
158
+
159
+ <article class="card">
160
+ <div class="card-head">
161
+ <h2>Phrase Flow</h2>
162
+ <span class="status-pill" id="phrase-status">Waiting for MIDI</span>
163
+ </div>
164
+ <div class="button-row">
165
+ <button id="send-phrase-button">Send Phrase</button>
166
+ <button id="replay-generated-button" class="ghost">Replay Output</button>
167
+ <button id="clear-phrase-button" class="ghost">Clear</button>
168
+ </div>
169
+ <dl class="info-grid">
170
+ <div>
171
+ <dt>Captured Events</dt>
172
+ <dd id="captured-event-count">0</dd>
173
+ </div>
174
+ <div>
175
+ <dt>Captured Notes</dt>
176
+ <dd id="captured-note-count">0</dd>
177
+ </div>
178
+ <div>
179
+ <dt>Generated Events</dt>
180
+ <dd id="generated-event-count">0</dd>
181
+ </div>
182
+ <div>
183
+ <dt>Generated Notes</dt>
184
+ <dd id="generated-note-count">0</dd>
185
+ </div>
186
+ </dl>
187
+ <div class="message-box" id="message-box">
188
+ Play a short phrase on the selected MIDI input to capture it.
189
+ </div>
190
+ </article>
191
+ </section>
192
+
193
+ <section class="column wide-column">
194
+ <article class="card roll-card">
195
+ <div class="card-head">
196
+ <h2>Captured Phrase</h2>
197
+ <span class="small-copy">Detected from browser MIDI events</span>
198
+ </div>
199
+ <canvas id="input-roll" class="piano-roll"></canvas>
200
+ </article>
201
+
202
+ <article class="card roll-card output-card">
203
+ <div class="card-head">
204
+ <h2>Generated Continuation</h2>
205
+ <span class="small-copy">Returned by the Python Continuator engine</span>
206
+ </div>
207
+ <canvas id="output-roll" class="piano-roll"></canvas>
208
+ </article>
209
+
210
+ <article class="card">
211
+ <div class="card-head">
212
+ <h2>Session Activity</h2>
213
+ <span class="small-copy">History log and active Continuator memory</span>
214
+ </div>
215
+ <div class="view-tabs" role="tablist" aria-label="Session activity views">
216
+ <button
217
+ id="history-tab"
218
+ class="view-tab is-active"
219
+ data-view-tab="history"
220
+ type="button"
221
+ role="tab"
222
+ aria-selected="true"
223
+ aria-controls="history-panel"
224
+ >
225
+ History
226
+ </button>
227
+ <button
228
+ id="memory-tab"
229
+ class="view-tab"
230
+ data-view-tab="memory"
231
+ type="button"
232
+ role="tab"
233
+ aria-selected="false"
234
+ aria-controls="memory-panel"
235
+ >
236
+ Memory
237
+ </button>
238
+ </div>
239
+ <section id="history-panel" class="view-panel" role="tabpanel">
240
+ <div id="history-list" class="history-list">
241
+ <p class="muted">Create a session to start building history.</p>
242
+ </div>
243
+ </section>
244
+ <section id="memory-panel" class="view-panel" role="tabpanel" hidden>
245
+ <div id="memory-summary" class="settings-summary">
246
+ <span class="settings-chip">No active memory yet</span>
247
+ </div>
248
+ <p id="memory-hint" class="small-copy memory-hint">
249
+ Create a session and play a phrase to inspect the active Continuator memory.
250
+ </p>
251
+ <div id="memory-ribbon" class="memory-ribbon"></div>
252
+ <div id="memory-list" class="history-list">
253
+ <p class="muted">No active memory to show yet.</p>
254
+ </div>
255
+ </section>
256
+ </article>
257
+ </section>
258
+ </main>
259
+
260
+ <script type="module" src="/assets/app.js"></script>
261
+ </body>
262
+ </html>
frontend/styles.css ADDED
@@ -0,0 +1,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #071217;
3
+ --panel: rgba(7, 18, 23, 0.76);
4
+ --panel-strong: rgba(10, 24, 30, 0.9);
5
+ --border: rgba(181, 215, 207, 0.14);
6
+ --text: #ecf4ef;
7
+ --muted: #9bb8b1;
8
+ --accent: #f4a261;
9
+ --accent-soft: rgba(244, 162, 97, 0.18);
10
+ --secondary: #6dd3ce;
11
+ --secondary-soft: rgba(109, 211, 206, 0.16);
12
+ --danger: #ff8e72;
13
+ --shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
14
+ --radius: 24px;
15
+ }
16
+
17
+ * {
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ html,
22
+ body {
23
+ margin: 0;
24
+ min-height: 100%;
25
+ }
26
+
27
+ body {
28
+ position: relative;
29
+ overflow-x: hidden;
30
+ background:
31
+ radial-gradient(circle at top left, rgba(109, 211, 206, 0.18), transparent 34%),
32
+ radial-gradient(circle at top right, rgba(244, 162, 97, 0.16), transparent 28%),
33
+ linear-gradient(180deg, #071217 0%, #0d1f27 50%, #081118 100%);
34
+ color: var(--text);
35
+ font-family: "Avenir Next", "Segoe UI Variable", "Trebuchet MS", sans-serif;
36
+ }
37
+
38
+ .page-aura,
39
+ .page-grid {
40
+ position: fixed;
41
+ inset: 0;
42
+ pointer-events: none;
43
+ }
44
+
45
+ .page-aura {
46
+ background:
47
+ radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.07), transparent 18%),
48
+ radial-gradient(circle at 82% 8%, rgba(255, 255, 255, 0.05), transparent 14%);
49
+ opacity: 0.85;
50
+ }
51
+
52
+ .page-grid {
53
+ background-image:
54
+ linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
55
+ linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
56
+ background-size: 56px 56px;
57
+ mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.55), transparent 85%);
58
+ }
59
+
60
+ .hero,
61
+ .layout {
62
+ position: relative;
63
+ z-index: 1;
64
+ }
65
+
66
+ .hero {
67
+ display: grid;
68
+ grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
69
+ gap: 24px;
70
+ align-items: end;
71
+ padding: 56px 28px 24px;
72
+ animation: rise-in 700ms ease;
73
+ }
74
+
75
+ .hero-copy h1 {
76
+ margin: 8px 0 14px;
77
+ font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
78
+ font-size: clamp(2.4rem, 5vw, 4.4rem);
79
+ line-height: 0.95;
80
+ letter-spacing: -0.03em;
81
+ }
82
+
83
+ .eyebrow {
84
+ margin: 0;
85
+ color: var(--secondary);
86
+ font-size: 0.82rem;
87
+ letter-spacing: 0.18em;
88
+ text-transform: uppercase;
89
+ }
90
+
91
+ .lede {
92
+ margin: 0;
93
+ max-width: 60ch;
94
+ color: var(--muted);
95
+ font-size: 1.05rem;
96
+ line-height: 1.65;
97
+ }
98
+
99
+ .hero-chip {
100
+ display: flex;
101
+ flex-wrap: wrap;
102
+ gap: 10px;
103
+ justify-content: flex-end;
104
+ }
105
+
106
+ .hero-chip span {
107
+ padding: 10px 14px;
108
+ border: 1px solid var(--border);
109
+ border-radius: 999px;
110
+ background: rgba(6, 15, 19, 0.58);
111
+ backdrop-filter: blur(10px);
112
+ color: var(--muted);
113
+ font-size: 0.92rem;
114
+ }
115
+
116
+ .layout {
117
+ display: grid;
118
+ grid-template-columns: minmax(300px, 0.92fr) minmax(0, 1.28fr);
119
+ gap: 22px;
120
+ padding: 0 28px 32px;
121
+ }
122
+
123
+ .column {
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: 22px;
127
+ }
128
+
129
+ .wide-column {
130
+ min-width: 0;
131
+ }
132
+
133
+ .card {
134
+ position: relative;
135
+ overflow: hidden;
136
+ border: 1px solid var(--border);
137
+ border-radius: var(--radius);
138
+ background: var(--panel);
139
+ backdrop-filter: blur(18px);
140
+ box-shadow: var(--shadow);
141
+ padding: 22px;
142
+ animation: rise-in 700ms ease;
143
+ }
144
+
145
+ .card::before {
146
+ content: "";
147
+ position: absolute;
148
+ inset: 0 0 auto;
149
+ height: 1px;
150
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.34), transparent);
151
+ }
152
+
153
+ .accent-card {
154
+ background:
155
+ linear-gradient(135deg, rgba(109, 211, 206, 0.08), transparent 50%),
156
+ var(--panel-strong);
157
+ }
158
+
159
+ .output-card {
160
+ background:
161
+ linear-gradient(135deg, rgba(244, 162, 97, 0.08), transparent 55%),
162
+ var(--panel-strong);
163
+ }
164
+
165
+ .card-head {
166
+ display: flex;
167
+ gap: 12px;
168
+ align-items: center;
169
+ justify-content: space-between;
170
+ margin-bottom: 14px;
171
+ }
172
+
173
+ .card-head h2 {
174
+ margin: 0;
175
+ font-size: 1.18rem;
176
+ }
177
+
178
+ .small-copy,
179
+ .muted {
180
+ color: var(--muted);
181
+ }
182
+
183
+ .muted {
184
+ margin: 0 0 18px;
185
+ line-height: 1.55;
186
+ }
187
+
188
+ .button-row {
189
+ display: flex;
190
+ flex-wrap: wrap;
191
+ gap: 10px;
192
+ margin-bottom: 18px;
193
+ }
194
+
195
+ button,
196
+ select,
197
+ input {
198
+ font: inherit;
199
+ }
200
+
201
+ button {
202
+ cursor: pointer;
203
+ border: 0;
204
+ border-radius: 999px;
205
+ background: linear-gradient(135deg, #f4a261, #ef7f58);
206
+ color: #081118;
207
+ font-weight: 700;
208
+ padding: 11px 16px;
209
+ transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
210
+ box-shadow: 0 12px 30px rgba(239, 127, 88, 0.22);
211
+ }
212
+
213
+ button:hover:not(:disabled) {
214
+ transform: translateY(-1px);
215
+ }
216
+
217
+ button:disabled {
218
+ cursor: not-allowed;
219
+ opacity: 0.55;
220
+ }
221
+
222
+ button.ghost {
223
+ background: rgba(255, 255, 255, 0.06);
224
+ color: var(--text);
225
+ box-shadow: none;
226
+ border: 1px solid var(--border);
227
+ }
228
+
229
+ .view-tabs {
230
+ display: inline-flex;
231
+ gap: 8px;
232
+ padding: 6px;
233
+ margin-bottom: 18px;
234
+ border-radius: 999px;
235
+ background: rgba(255, 255, 255, 0.04);
236
+ border: 1px solid rgba(255, 255, 255, 0.06);
237
+ }
238
+
239
+ .view-tab {
240
+ border: 1px solid transparent;
241
+ border-radius: 999px;
242
+ background: transparent;
243
+ color: var(--muted);
244
+ box-shadow: none;
245
+ padding: 9px 14px;
246
+ }
247
+
248
+ .view-tab.is-active {
249
+ background: linear-gradient(135deg, rgba(244, 162, 97, 0.24), rgba(109, 211, 206, 0.16));
250
+ border-color: rgba(255, 255, 255, 0.08);
251
+ color: var(--text);
252
+ }
253
+
254
+ .view-panel[hidden] {
255
+ display: none;
256
+ }
257
+
258
+ .status-pill {
259
+ border-radius: 999px;
260
+ padding: 8px 12px;
261
+ background: rgba(255, 255, 255, 0.05);
262
+ color: var(--muted);
263
+ font-size: 0.88rem;
264
+ }
265
+
266
+ .field {
267
+ display: grid;
268
+ gap: 8px;
269
+ margin-bottom: 14px;
270
+ }
271
+
272
+ .field label,
273
+ .toggle {
274
+ font-size: 0.95rem;
275
+ }
276
+
277
+ select {
278
+ width: 100%;
279
+ border: 1px solid var(--border);
280
+ border-radius: 16px;
281
+ background: rgba(255, 255, 255, 0.05);
282
+ color: var(--text);
283
+ padding: 12px 14px;
284
+ }
285
+
286
+ input[type="number"] {
287
+ width: 100%;
288
+ border: 1px solid var(--border);
289
+ border-radius: 16px;
290
+ background: rgba(255, 255, 255, 0.05);
291
+ color: var(--text);
292
+ padding: 12px 14px;
293
+ }
294
+
295
+ input[type="number"]:disabled {
296
+ opacity: 0.55;
297
+ }
298
+
299
+ .toggle {
300
+ display: inline-flex;
301
+ align-items: center;
302
+ gap: 10px;
303
+ color: var(--muted);
304
+ }
305
+
306
+ .toggle input {
307
+ accent-color: var(--accent);
308
+ }
309
+
310
+ .settings-compact {
311
+ margin-bottom: 18px;
312
+ }
313
+
314
+ .settings-label-row {
315
+ display: flex;
316
+ align-items: center;
317
+ justify-content: space-between;
318
+ gap: 12px;
319
+ margin-bottom: 10px;
320
+ }
321
+
322
+ .settings-summary {
323
+ display: flex;
324
+ flex-wrap: wrap;
325
+ gap: 10px;
326
+ }
327
+
328
+ .settings-chip {
329
+ display: inline-flex;
330
+ align-items: center;
331
+ gap: 8px;
332
+ padding: 9px 12px;
333
+ border-radius: 999px;
334
+ border: 1px solid rgba(255, 255, 255, 0.07);
335
+ background: rgba(255, 255, 255, 0.04);
336
+ color: var(--muted);
337
+ font-size: 0.88rem;
338
+ }
339
+
340
+ .settings-panel {
341
+ margin-bottom: 18px;
342
+ border: 1px solid rgba(255, 255, 255, 0.06);
343
+ border-radius: 20px;
344
+ background: rgba(255, 255, 255, 0.03);
345
+ overflow: hidden;
346
+ }
347
+
348
+ .settings-panel summary {
349
+ cursor: pointer;
350
+ list-style: none;
351
+ padding: 16px 18px;
352
+ font-weight: 700;
353
+ }
354
+
355
+ .settings-panel summary::-webkit-details-marker {
356
+ display: none;
357
+ }
358
+
359
+ .settings-panel[open] summary {
360
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
361
+ }
362
+
363
+ .settings-panel > :not(summary) {
364
+ margin-left: 18px;
365
+ margin-right: 18px;
366
+ }
367
+
368
+ .settings-copy {
369
+ margin-top: 14px;
370
+ }
371
+
372
+ .settings-grid {
373
+ display: grid;
374
+ grid-template-columns: repeat(2, minmax(0, 1fr));
375
+ gap: 16px;
376
+ }
377
+
378
+ .settings-toggle {
379
+ min-height: 54px;
380
+ padding: 14px 16px;
381
+ border-radius: 18px;
382
+ background: rgba(255, 255, 255, 0.04);
383
+ border: 1px solid rgba(255, 255, 255, 0.05);
384
+ }
385
+
386
+ .field-hint {
387
+ margin: 8px 0 0;
388
+ color: var(--muted);
389
+ font-size: 0.84rem;
390
+ line-height: 1.45;
391
+ }
392
+
393
+ .info-grid {
394
+ display: grid;
395
+ grid-template-columns: repeat(2, minmax(0, 1fr));
396
+ gap: 12px;
397
+ margin: 0;
398
+ }
399
+
400
+ .info-grid div {
401
+ padding: 14px;
402
+ border-radius: 18px;
403
+ background: rgba(255, 255, 255, 0.04);
404
+ border: 1px solid rgba(255, 255, 255, 0.05);
405
+ }
406
+
407
+ .info-grid dt {
408
+ color: var(--muted);
409
+ font-size: 0.82rem;
410
+ margin-bottom: 6px;
411
+ }
412
+
413
+ .info-grid dd {
414
+ margin: 0;
415
+ word-break: break-word;
416
+ font-weight: 600;
417
+ }
418
+
419
+ .message-box {
420
+ min-height: 64px;
421
+ border-radius: 18px;
422
+ padding: 14px;
423
+ background: linear-gradient(135deg, rgba(109, 211, 206, 0.08), rgba(255, 255, 255, 0.02));
424
+ border: 1px solid var(--border);
425
+ color: var(--muted);
426
+ line-height: 1.55;
427
+ }
428
+
429
+ .roll-card {
430
+ padding-bottom: 16px;
431
+ }
432
+
433
+ .piano-roll {
434
+ width: 100%;
435
+ height: 244px;
436
+ display: block;
437
+ border-radius: 18px;
438
+ background:
439
+ linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
440
+ rgba(255, 255, 255, 0.02);
441
+ border: 1px solid rgba(255, 255, 255, 0.06);
442
+ }
443
+
444
+ .history-list {
445
+ display: grid;
446
+ gap: 10px;
447
+ }
448
+
449
+ .memory-hint {
450
+ margin: 12px 0 14px;
451
+ line-height: 1.5;
452
+ }
453
+
454
+ .memory-ribbon {
455
+ display: grid;
456
+ grid-template-columns: repeat(auto-fit, minmax(28px, 1fr));
457
+ gap: 8px;
458
+ margin-bottom: 16px;
459
+ }
460
+
461
+ .memory-ribbon:empty {
462
+ display: none;
463
+ }
464
+
465
+ .memory-ribbon-cell {
466
+ min-height: 42px;
467
+ padding: 0;
468
+ border-radius: 12px;
469
+ border: 1px solid rgba(255, 255, 255, 0.07);
470
+ background: rgba(255, 255, 255, 0.04);
471
+ box-shadow: none;
472
+ }
473
+
474
+ .memory-ribbon-cell.seed {
475
+ background: rgba(109, 211, 206, 0.14);
476
+ }
477
+
478
+ .memory-ribbon-cell.live {
479
+ background: rgba(244, 162, 97, 0.16);
480
+ }
481
+
482
+ .memory-item .history-kind {
483
+ min-width: 96px;
484
+ }
485
+
486
+ .history-item {
487
+ display: grid;
488
+ grid-template-columns: auto 1fr auto;
489
+ gap: 14px;
490
+ align-items: center;
491
+ width: 100%;
492
+ text-align: left;
493
+ box-shadow: none;
494
+ border-radius: 18px;
495
+ border: 1px solid rgba(255, 255, 255, 0.06);
496
+ padding: 14px;
497
+ background: rgba(255, 255, 255, 0.03);
498
+ color: var(--text);
499
+ }
500
+
501
+ .history-item:hover {
502
+ border-color: rgba(244, 162, 97, 0.28);
503
+ }
504
+
505
+ .history-kind {
506
+ display: inline-flex;
507
+ align-items: center;
508
+ justify-content: center;
509
+ min-width: 82px;
510
+ padding: 8px 10px;
511
+ border-radius: 999px;
512
+ background: rgba(255, 255, 255, 0.05);
513
+ color: var(--muted);
514
+ font-size: 0.82rem;
515
+ text-transform: capitalize;
516
+ }
517
+
518
+ .history-meta {
519
+ display: grid;
520
+ gap: 4px;
521
+ }
522
+
523
+ .history-meta strong {
524
+ font-size: 0.96rem;
525
+ }
526
+
527
+ .history-meta span {
528
+ color: var(--muted);
529
+ font-size: 0.88rem;
530
+ }
531
+
532
+ .history-index {
533
+ color: var(--accent);
534
+ font-size: 0.85rem;
535
+ }
536
+
537
+ @keyframes rise-in {
538
+ from {
539
+ opacity: 0;
540
+ transform: translateY(10px);
541
+ }
542
+
543
+ to {
544
+ opacity: 1;
545
+ transform: translateY(0);
546
+ }
547
+ }
548
+
549
+ @media (max-width: 980px) {
550
+ .hero,
551
+ .layout {
552
+ grid-template-columns: 1fr;
553
+ }
554
+
555
+ .hero {
556
+ padding-top: 40px;
557
+ }
558
+
559
+ .hero-chip {
560
+ justify-content: flex-start;
561
+ }
562
+ }
563
+
564
+ @media (max-width: 640px) {
565
+ .hero,
566
+ .layout {
567
+ padding-left: 16px;
568
+ padding-right: 16px;
569
+ }
570
+
571
+ .card {
572
+ padding: 18px;
573
+ }
574
+
575
+ .info-grid {
576
+ grid-template-columns: 1fr;
577
+ }
578
+
579
+ .settings-grid {
580
+ grid-template-columns: 1fr;
581
+ }
582
+
583
+ .piano-roll {
584
+ height: 206px;
585
+ }
586
+ }