Spaces:
Running
Running
Commit ·
70e4406
0
Parent(s):
Initialize Continuator Web Pilot
Browse files- .dockerignore +13 -0
- .gitignore +9 -0
- Dockerfile +22 -0
- README.md +392 -0
- backend/app/__init__.py +2 -0
- backend/app/config.py +41 -0
- backend/app/continuator_adapter.py +271 -0
- backend/app/main.py +133 -0
- backend/app/schemas.py +141 -0
- backend/app/session_manager.py +230 -0
- backend/app/storage.py +182 -0
- backend/data/.gitkeep +1 -0
- backend/requirements.txt +6 -0
- backend/vendor/LICENSE.continuator +21 -0
- backend/vendor/ctor/__init__.py +0 -0
- backend/vendor/ctor/belief_propag.py +320 -0
- backend/vendor/ctor/continuator.py +486 -0
- backend/vendor/ctor/dynaprog.py +184 -0
- backend/vendor/ctor/markov_analysis.py +159 -0
- backend/vendor/ctor/variable_order_markov.py +828 -0
- backend/vendor/midi_stuff/__init__.py +2 -0
- backend/vendor/midi_stuff/mini_muse.py +212 -0
- frontend/app.js +1183 -0
- frontend/index.html +262 -0
- frontend/styles.css +586 -0
.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 |
+
}
|