Spaces:
Running
Running
Add session MIDI ZIP export
Browse files- .dockerignore +1 -1
- .gitignore +1 -1
- README.md +40 -0
- backend/app/config.py +3 -0
- backend/app/main.py +73 -0
- backend/app/midi_export.py +67 -0
- backend/app/schemas.py +40 -0
- backend/app/session_manager.py +153 -0
- backend/app/storage.py +53 -0
- frontend/app.js +300 -0
- frontend/index.html +28 -2
- frontend/styles.css +18 -0
.dockerignore
CHANGED
|
@@ -10,4 +10,4 @@ venv
|
|
| 10 |
node_modules
|
| 11 |
backend/data/*.sqlite3
|
| 12 |
backend/data/*.sqlite3-*
|
| 13 |
-
|
|
|
|
| 10 |
node_modules
|
| 11 |
backend/data/*.sqlite3
|
| 12 |
backend/data/*.sqlite3-*
|
| 13 |
+
backend/data/session_midi_exports
|
.gitignore
CHANGED
|
@@ -6,4 +6,4 @@ venv/
|
|
| 6 |
node_modules/
|
| 7 |
backend/data/*.sqlite3
|
| 8 |
backend/data/*.sqlite3-*
|
| 9 |
-
|
|
|
|
| 6 |
node_modules/
|
| 7 |
backend/data/*.sqlite3
|
| 8 |
backend/data/*.sqlite3-*
|
| 9 |
+
backend/data/session_midi_exports/
|
README.md
CHANGED
|
@@ -27,6 +27,7 @@ The current implementation is meant as an MVP for experimentation, demos, and ar
|
|
| 27 |
- A `FastAPI` backend that wraps the Python Continuator engine.
|
| 28 |
- One isolated Continuator engine per live session.
|
| 29 |
- SQLite logging for captured and generated phrases.
|
|
|
|
| 30 |
- A compact UI with MIDI controls, phrase playback, and session activity views.
|
| 31 |
- A tabbed `History | Memory` activity card.
|
| 32 |
- Docker packaging suitable for local containers and Hugging Face Docker Spaces.
|
|
@@ -53,6 +54,7 @@ What is implemented today:
|
|
| 53 |
- Phrase capture based on silence detection.
|
| 54 |
- Continuator settings exposed through a compact advanced drawer.
|
| 55 |
- Per-session `History` and `Memory` visualization.
|
|
|
|
| 56 |
|
| 57 |
What is intentionally not implemented yet:
|
| 58 |
|
|
@@ -66,6 +68,7 @@ What is intentionally not implemented yet:
|
|
| 66 |
The UI is organized around a simple performance loop:
|
| 67 |
|
| 68 |
- `Session`: create a new isolated Continuator session or reset its memory.
|
|
|
|
| 69 |
- `MIDI I/O`: connect browser MIDI, choose an input port, and choose a playback output.
|
| 70 |
- `Phrase Flow`: monitor captured and generated note counts, send a phrase manually, replay the last continuation, or clear local buffers.
|
| 71 |
- `Session Activity`: switch between `History` and `Memory`.
|
|
@@ -76,6 +79,27 @@ The UX principle is to keep the performance flow visible at all times and hide l
|
|
| 76 |
- Advanced model controls live in the `Advanced Continuator Settings` drawer.
|
| 77 |
- Memory inspection stays inside a local tabbed card rather than taking over the page.
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
## High-Level Architecture
|
| 80 |
|
| 81 |
```mermaid
|
|
@@ -185,6 +209,7 @@ backend/
|
|
| 185 |
config.py
|
| 186 |
continuator_adapter.py
|
| 187 |
main.py
|
|
|
|
| 188 |
schemas.py
|
| 189 |
session_manager.py
|
| 190 |
storage.py
|
|
@@ -203,6 +228,7 @@ Main responsibilities:
|
|
| 203 |
- `frontend/app.js`: MIDI capture, phrase segmentation, API calls, playback, and client-side visualization.
|
| 204 |
- `frontend/styles.css`: layout and visual design.
|
| 205 |
- `backend/app/main.py`: FastAPI routes.
|
|
|
|
| 206 |
- `backend/app/session_manager.py`: session lifecycle, orchestration, and API-facing session logic.
|
| 207 |
- `backend/app/continuator_adapter.py`: bridge between web JSON payloads and the Continuator Python engine.
|
| 208 |
- `backend/app/storage.py`: SQLite session and phrase logging.
|
|
@@ -220,6 +246,9 @@ Current API endpoints:
|
|
| 220 |
- `POST /api/sessions/{session_id}/generate`: generate a phrase directly from memory.
|
| 221 |
- `GET /api/sessions/{session_id}/history`: retrieve recent logged phrase history.
|
| 222 |
- `GET /api/sessions/{session_id}/memory`: retrieve the active memory snapshot for the live engine.
|
|
|
|
|
|
|
|
|
|
| 223 |
- `POST /api/sessions/{session_id}/reset`: clear the live engine memory while preserving session settings.
|
| 224 |
|
| 225 |
API payloads use JSON and expose both:
|
|
@@ -268,6 +297,7 @@ Typical local interaction:
|
|
| 268 |
7. Wait until the selected phrase gap has elapsed after the final note release. The default gap is 1 second.
|
| 269 |
8. Let auto-send submit the phrase, or click `Send Phrase`.
|
| 270 |
9. Inspect `History` or `Memory` in the `Session Activity` card.
|
|
|
|
| 271 |
|
| 272 |
When you change frontend code, a normal refresh is usually enough.
|
| 273 |
When you change backend models or routes, restarting the server and refreshing the page is the safest option.
|
|
@@ -278,6 +308,7 @@ Environment variables currently supported:
|
|
| 278 |
|
| 279 |
- `CONTINUATOR_APP_NAME`: override the displayed app name.
|
| 280 |
- `CONTINUATOR_DB_PATH`: move the SQLite database to another location.
|
|
|
|
| 281 |
- `CONTINUATOR_SEED_MIDI_FILE`: preload each new session with one MIDI file.
|
| 282 |
- `CONTINUATOR_SEED_MIDI_FOLDER`: preload each new session with a folder of MIDI files.
|
| 283 |
|
|
@@ -286,6 +317,7 @@ Examples:
|
|
| 286 |
```bash
|
| 287 |
export CONTINUATOR_SEED_MIDI_FILE=/absolute/path/to/example.mid
|
| 288 |
export CONTINUATOR_DB_PATH=/absolute/path/to/continuator.sqlite3
|
|
|
|
| 289 |
```
|
| 290 |
|
| 291 |
## Optional Seed Corpus
|
|
@@ -336,6 +368,12 @@ If you want phrase history to survive restarts, attach a storage volume or bucke
|
|
| 336 |
CONTINUATOR_DB_PATH=/data/continuator.sqlite3
|
| 337 |
```
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
If you want each new session to start from a corpus, you can also define:
|
| 340 |
|
| 341 |
```bash
|
|
@@ -348,6 +386,7 @@ For a first public demo, the simplest deployment is:
|
|
| 348 |
- free CPU hardware
|
| 349 |
- no persistent storage
|
| 350 |
- no seed corpus
|
|
|
|
| 351 |
|
| 352 |
That keeps the setup minimal and is enough to validate browser MIDI capture, API round-trips, and continuation playback.
|
| 353 |
|
|
@@ -367,6 +406,7 @@ Things to keep in mind:
|
|
| 367 |
- The current live session registry is in process memory, so scaling to multiple replicas would require shared session storage.
|
| 368 |
- SQLite is fine for a prototype, but PostgreSQL would be a better next step for heavier concurrent use.
|
| 369 |
- Persistent user memory is not implemented yet.
|
|
|
|
| 370 |
- Long-lived or heavier multi-user deployments will need explicit storage, authentication, and horizontal architecture decisions.
|
| 371 |
|
| 372 |
## Local Mac Usage After Space Setup
|
|
|
|
| 27 |
- A `FastAPI` backend that wraps the Python Continuator engine.
|
| 28 |
- One isolated Continuator engine per live session.
|
| 29 |
- SQLite logging for captured and generated phrases.
|
| 30 |
+
- Session MIDI export for all played/generated phrase pairs.
|
| 31 |
- A compact UI with MIDI controls, phrase playback, and session activity views.
|
| 32 |
- A tabbed `History | Memory` activity card.
|
| 33 |
- Docker packaging suitable for local containers and Hugging Face Docker Spaces.
|
|
|
|
| 54 |
- Phrase capture based on silence detection.
|
| 55 |
- Continuator settings exposed through a compact advanced drawer.
|
| 56 |
- Per-session `History` and `Memory` visualization.
|
| 57 |
+
- `Save Session MIDI` export from the Session panel.
|
| 58 |
|
| 59 |
What is intentionally not implemented yet:
|
| 60 |
|
|
|
|
| 68 |
The UI is organized around a simple performance loop:
|
| 69 |
|
| 70 |
- `Session`: create a new isolated Continuator session or reset its memory.
|
| 71 |
+
- `Save Session MIDI`: export every played/generated phrase pair from the current session as MIDI files in one ZIP archive.
|
| 72 |
- `MIDI I/O`: connect browser MIDI, choose an input port, and choose a playback output.
|
| 73 |
- `Phrase Flow`: monitor captured and generated note counts, send a phrase manually, replay the last continuation, or clear local buffers.
|
| 74 |
- `Session Activity`: switch between `History` and `Memory`.
|
|
|
|
| 79 |
- Advanced model controls live in the `Advanced Continuator Settings` drawer.
|
| 80 |
- Memory inspection stays inside a local tabbed card rather than taking over the page.
|
| 81 |
|
| 82 |
+
## Saving Session MIDI
|
| 83 |
+
|
| 84 |
+
The Session panel includes `Save Session MIDI`.
|
| 85 |
+
|
| 86 |
+
When clicked, the browser asks the backend for a ZIP archive containing one `.mid`
|
| 87 |
+
file for each played input phrase and each generated phrase in the current
|
| 88 |
+
session. Filenames preserve the phrase order and label files as `input` or
|
| 89 |
+
`generated`.
|
| 90 |
+
|
| 91 |
+
In browsers that support the File System Access API, such as Chrome and Edge,
|
| 92 |
+
the button opens a file-save picker for the ZIP archive. The checkbox labelled
|
| 93 |
+
`Don't ask again` remembers the selected ZIP file handle in browser storage and
|
| 94 |
+
overwrites that same ZIP on future saves after permission is granted. This
|
| 95 |
+
intentionally remembers a file, not a folder, because Chrome can reject folder
|
| 96 |
+
access for protected locations.
|
| 97 |
+
|
| 98 |
+
If the browser does not support the file-save picker, the export falls back to a
|
| 99 |
+
normal browser download. The backend also keeps `POST /api/sessions/{session_id}/save-midi`
|
| 100 |
+
as a server-side fallback route, but browser users should prefer the ZIP
|
| 101 |
+
download path because it writes to a location they choose locally.
|
| 102 |
+
|
| 103 |
## High-Level Architecture
|
| 104 |
|
| 105 |
```mermaid
|
|
|
|
| 209 |
config.py
|
| 210 |
continuator_adapter.py
|
| 211 |
main.py
|
| 212 |
+
midi_export.py
|
| 213 |
schemas.py
|
| 214 |
session_manager.py
|
| 215 |
storage.py
|
|
|
|
| 228 |
- `frontend/app.js`: MIDI capture, phrase segmentation, API calls, playback, and client-side visualization.
|
| 229 |
- `frontend/styles.css`: layout and visual design.
|
| 230 |
- `backend/app/main.py`: FastAPI routes.
|
| 231 |
+
- `backend/app/midi_export.py`: phrase-payload to MIDI conversion and ZIP packaging.
|
| 232 |
- `backend/app/session_manager.py`: session lifecycle, orchestration, and API-facing session logic.
|
| 233 |
- `backend/app/continuator_adapter.py`: bridge between web JSON payloads and the Continuator Python engine.
|
| 234 |
- `backend/app/storage.py`: SQLite session and phrase logging.
|
|
|
|
| 246 |
- `POST /api/sessions/{session_id}/generate`: generate a phrase directly from memory.
|
| 247 |
- `GET /api/sessions/{session_id}/history`: retrieve recent logged phrase history.
|
| 248 |
- `GET /api/sessions/{session_id}/memory`: retrieve the active memory snapshot for the live engine.
|
| 249 |
+
- `GET /api/sessions/{session_id}/midi-download`: return played and generated phrases as base64 MIDI file payloads.
|
| 250 |
+
- `GET /api/sessions/{session_id}/midi.zip`: download played and generated phrases as a ZIP archive for the browser save/download flow.
|
| 251 |
+
- `POST /api/sessions/{session_id}/save-midi`: save played and generated phrases as MIDI files on the backend as a server-side fallback.
|
| 252 |
- `POST /api/sessions/{session_id}/reset`: clear the live engine memory while preserving session settings.
|
| 253 |
|
| 254 |
API payloads use JSON and expose both:
|
|
|
|
| 297 |
7. Wait until the selected phrase gap has elapsed after the final note release. The default gap is 1 second.
|
| 298 |
8. Let auto-send submit the phrase, or click `Send Phrase`.
|
| 299 |
9. Inspect `History` or `Memory` in the `Session Activity` card.
|
| 300 |
+
10. Use `Save Session MIDI` in the Session panel to export the played/generated phrases as a ZIP archive of MIDI files.
|
| 301 |
|
| 302 |
When you change frontend code, a normal refresh is usually enough.
|
| 303 |
When you change backend models or routes, restarting the server and refreshing the page is the safest option.
|
|
|
|
| 308 |
|
| 309 |
- `CONTINUATOR_APP_NAME`: override the displayed app name.
|
| 310 |
- `CONTINUATOR_DB_PATH`: move the SQLite database to another location.
|
| 311 |
+
- `CONTINUATOR_SESSION_EXPORT_DIR`: choose where fallback backend-side MIDI exports are written.
|
| 312 |
- `CONTINUATOR_SEED_MIDI_FILE`: preload each new session with one MIDI file.
|
| 313 |
- `CONTINUATOR_SEED_MIDI_FOLDER`: preload each new session with a folder of MIDI files.
|
| 314 |
|
|
|
|
| 317 |
```bash
|
| 318 |
export CONTINUATOR_SEED_MIDI_FILE=/absolute/path/to/example.mid
|
| 319 |
export CONTINUATOR_DB_PATH=/absolute/path/to/continuator.sqlite3
|
| 320 |
+
export CONTINUATOR_SESSION_EXPORT_DIR=/absolute/path/to/session-midi-exports
|
| 321 |
```
|
| 322 |
|
| 323 |
## Optional Seed Corpus
|
|
|
|
| 368 |
CONTINUATOR_DB_PATH=/data/continuator.sqlite3
|
| 369 |
```
|
| 370 |
|
| 371 |
+
The browser ZIP export does not require persistent Space storage because the
|
| 372 |
+
archive is downloaded to the user. The server-side fallback `save-midi` route
|
| 373 |
+
writes inside the container unless `CONTINUATOR_SESSION_EXPORT_DIR` points to a
|
| 374 |
+
mounted volume, so it is mainly useful for local development or managed storage
|
| 375 |
+
setups.
|
| 376 |
+
|
| 377 |
If you want each new session to start from a corpus, you can also define:
|
| 378 |
|
| 379 |
```bash
|
|
|
|
| 386 |
- free CPU hardware
|
| 387 |
- no persistent storage
|
| 388 |
- no seed corpus
|
| 389 |
+
- browser-side MIDI ZIP export only
|
| 390 |
|
| 391 |
That keeps the setup minimal and is enough to validate browser MIDI capture, API round-trips, and continuation playback.
|
| 392 |
|
|
|
|
| 406 |
- The current live session registry is in process memory, so scaling to multiple replicas would require shared session storage.
|
| 407 |
- SQLite is fine for a prototype, but PostgreSQL would be a better next step for heavier concurrent use.
|
| 408 |
- Persistent user memory is not implemented yet.
|
| 409 |
+
- Browser MIDI ZIP exports are user downloads; backend-side MIDI export folders need mounted storage if they should survive a Space rebuild.
|
| 410 |
- Long-lived or heavier multi-user deployments will need explicit storage, authentication, and horizontal architecture decisions.
|
| 411 |
|
| 412 |
## Local Mac Usage After Space Setup
|
backend/app/config.py
CHANGED
|
@@ -16,6 +16,7 @@ class Settings:
|
|
| 16 |
app_name: str
|
| 17 |
frontend_dir: Path
|
| 18 |
db_path: Path
|
|
|
|
| 19 |
seed_midi_file: Path | None
|
| 20 |
seed_midi_folder: Path | None
|
| 21 |
|
|
@@ -32,6 +33,8 @@ def load_settings() -> Settings:
|
|
| 32 |
app_name=os.getenv("CONTINUATOR_APP_NAME", "Web Continuator"),
|
| 33 |
frontend_dir=FRONTEND_DIR,
|
| 34 |
db_path=_env_path("CONTINUATOR_DB_PATH") or (DATA_DIR / "continuator.sqlite3"),
|
|
|
|
|
|
|
| 35 |
seed_midi_file=_env_path("CONTINUATOR_SEED_MIDI_FILE"),
|
| 36 |
seed_midi_folder=_env_path("CONTINUATOR_SEED_MIDI_FOLDER"),
|
| 37 |
)
|
|
|
|
| 16 |
app_name: str
|
| 17 |
frontend_dir: Path
|
| 18 |
db_path: Path
|
| 19 |
+
session_export_dir: Path
|
| 20 |
seed_midi_file: Path | None
|
| 21 |
seed_midi_folder: Path | None
|
| 22 |
|
|
|
|
| 33 |
app_name=os.getenv("CONTINUATOR_APP_NAME", "Web Continuator"),
|
| 34 |
frontend_dir=FRONTEND_DIR,
|
| 35 |
db_path=_env_path("CONTINUATOR_DB_PATH") or (DATA_DIR / "continuator.sqlite3"),
|
| 36 |
+
session_export_dir=_env_path("CONTINUATOR_SESSION_EXPORT_DIR")
|
| 37 |
+
or (DATA_DIR / "session_midi_exports"),
|
| 38 |
seed_midi_file=_env_path("CONTINUATOR_SEED_MIDI_FILE"),
|
| 39 |
seed_midi_folder=_env_path("CONTINUATOR_SEED_MIDI_FOLDER"),
|
| 40 |
)
|
backend/app/main.py
CHANGED
|
@@ -21,6 +21,7 @@ from .schemas import (
|
|
| 21 |
ContinueResponse,
|
| 22 |
CreateSessionRequest,
|
| 23 |
CreateSessionResponse,
|
|
|
|
| 24 |
GeneratePhraseRequest,
|
| 25 |
GeneratePhraseResponse,
|
| 26 |
ImportMidiResponse,
|
|
@@ -30,6 +31,7 @@ from .schemas import (
|
|
| 30 |
PublicConfigResponse,
|
| 31 |
RegisterRequest,
|
| 32 |
ResetSessionResponse,
|
|
|
|
| 33 |
SessionHistoryResponse,
|
| 34 |
SessionMemoryResponse,
|
| 35 |
UpdateSessionNameRequest,
|
|
@@ -51,6 +53,7 @@ session_manager = SessionManager(
|
|
| 51 |
storage=storage,
|
| 52 |
seed_midi_file=settings.seed_midi_file,
|
| 53 |
seed_midi_folder=settings.seed_midi_folder,
|
|
|
|
| 54 |
)
|
| 55 |
|
| 56 |
|
|
@@ -331,6 +334,76 @@ def session_history(
|
|
| 331 |
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
|
| 332 |
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
@app.get(
|
| 335 |
"/api/sessions/{session_id}/memory",
|
| 336 |
response_model=SessionMemoryResponse,
|
|
|
|
| 21 |
ContinueResponse,
|
| 22 |
CreateSessionRequest,
|
| 23 |
CreateSessionResponse,
|
| 24 |
+
DownloadSessionMidiResponse,
|
| 25 |
GeneratePhraseRequest,
|
| 26 |
GeneratePhraseResponse,
|
| 27 |
ImportMidiResponse,
|
|
|
|
| 31 |
PublicConfigResponse,
|
| 32 |
RegisterRequest,
|
| 33 |
ResetSessionResponse,
|
| 34 |
+
SaveSessionMidiResponse,
|
| 35 |
SessionHistoryResponse,
|
| 36 |
SessionMemoryResponse,
|
| 37 |
UpdateSessionNameRequest,
|
|
|
|
| 53 |
storage=storage,
|
| 54 |
seed_midi_file=settings.seed_midi_file,
|
| 55 |
seed_midi_folder=settings.seed_midi_folder,
|
| 56 |
+
session_export_dir=settings.session_export_dir,
|
| 57 |
)
|
| 58 |
|
| 59 |
|
|
|
|
| 334 |
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
|
| 335 |
|
| 336 |
|
| 337 |
+
@app.post(
|
| 338 |
+
"/api/sessions/{session_id}/save-midi",
|
| 339 |
+
response_model=SaveSessionMidiResponse,
|
| 340 |
+
tags=["session"],
|
| 341 |
+
)
|
| 342 |
+
def save_session_midi(
|
| 343 |
+
session_id: str,
|
| 344 |
+
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
|
| 345 |
+
) -> SaveSessionMidiResponse:
|
| 346 |
+
try:
|
| 347 |
+
return session_manager.save_session_midi(
|
| 348 |
+
session_id,
|
| 349 |
+
None if current_user is None else current_user.id,
|
| 350 |
+
)
|
| 351 |
+
except UnknownSessionError as error:
|
| 352 |
+
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
|
| 353 |
+
except ValueError as error:
|
| 354 |
+
raise HTTPException(status_code=400, detail=str(error)) from error
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
@app.get(
|
| 358 |
+
"/api/sessions/{session_id}/midi-download",
|
| 359 |
+
response_model=DownloadSessionMidiResponse,
|
| 360 |
+
tags=["session"],
|
| 361 |
+
)
|
| 362 |
+
def download_session_midi(
|
| 363 |
+
session_id: str,
|
| 364 |
+
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
|
| 365 |
+
) -> DownloadSessionMidiResponse:
|
| 366 |
+
try:
|
| 367 |
+
return session_manager.download_session_midi(
|
| 368 |
+
session_id,
|
| 369 |
+
None if current_user is None else current_user.id,
|
| 370 |
+
)
|
| 371 |
+
except UnknownSessionError as error:
|
| 372 |
+
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
|
| 373 |
+
except ValueError as error:
|
| 374 |
+
raise HTTPException(status_code=400, detail=str(error)) from error
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
@app.get(
|
| 378 |
+
"/api/sessions/{session_id}/midi.zip",
|
| 379 |
+
tags=["session"],
|
| 380 |
+
)
|
| 381 |
+
def session_midi_zip(
|
| 382 |
+
session_id: str,
|
| 383 |
+
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
|
| 384 |
+
) -> Response:
|
| 385 |
+
try:
|
| 386 |
+
file_name, content, payload = session_manager.session_midi_zip(
|
| 387 |
+
session_id,
|
| 388 |
+
None if current_user is None else current_user.id,
|
| 389 |
+
)
|
| 390 |
+
except UnknownSessionError as error:
|
| 391 |
+
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
|
| 392 |
+
except ValueError as error:
|
| 393 |
+
raise HTTPException(status_code=400, detail=str(error)) from error
|
| 394 |
+
|
| 395 |
+
return Response(
|
| 396 |
+
content=content,
|
| 397 |
+
media_type="application/zip",
|
| 398 |
+
headers={
|
| 399 |
+
"Content-Disposition": f'attachment; filename="{file_name}"',
|
| 400 |
+
"X-Midi-File-Count": str(payload.file_count),
|
| 401 |
+
"X-Midi-Input-File-Count": str(payload.input_file_count),
|
| 402 |
+
"X-Midi-Generated-File-Count": str(payload.generated_file_count),
|
| 403 |
+
},
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
@app.get(
|
| 408 |
"/api/sessions/{session_id}/memory",
|
| 409 |
response_model=SessionMemoryResponse,
|
backend/app/midi_export.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from io import BytesIO
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from zipfile import ZIP_DEFLATED, ZipFile
|
| 6 |
+
|
| 7 |
+
import mido
|
| 8 |
+
|
| 9 |
+
from .schemas import PhrasePayload
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
MIDI_EXPORT_TEMPO = 500_000
|
| 13 |
+
MIDI_EXPORT_TICKS_PER_BEAT = 480
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def phrase_payload_to_midi_bytes(payload: PhrasePayload) -> bytes:
|
| 17 |
+
midi_file = mido.MidiFile(type=0, ticks_per_beat=MIDI_EXPORT_TICKS_PER_BEAT)
|
| 18 |
+
track = mido.MidiTrack()
|
| 19 |
+
midi_file.tracks.append(track)
|
| 20 |
+
track.append(mido.MetaMessage("set_tempo", tempo=MIDI_EXPORT_TEMPO, time=0))
|
| 21 |
+
|
| 22 |
+
current_seconds = 0.0
|
| 23 |
+
previous_tick = 0
|
| 24 |
+
for event in payload.events:
|
| 25 |
+
current_seconds += float(event.delta_seconds)
|
| 26 |
+
absolute_tick = max(
|
| 27 |
+
previous_tick,
|
| 28 |
+
int(
|
| 29 |
+
round(
|
| 30 |
+
mido.second2tick(
|
| 31 |
+
current_seconds,
|
| 32 |
+
MIDI_EXPORT_TICKS_PER_BEAT,
|
| 33 |
+
MIDI_EXPORT_TEMPO,
|
| 34 |
+
)
|
| 35 |
+
)
|
| 36 |
+
),
|
| 37 |
+
)
|
| 38 |
+
delta_ticks = absolute_tick - previous_tick
|
| 39 |
+
previous_tick = absolute_tick
|
| 40 |
+
velocity = int(event.velocity) if event.type == "note_on" else 0
|
| 41 |
+
track.append(
|
| 42 |
+
mido.Message(
|
| 43 |
+
event.type,
|
| 44 |
+
note=int(event.note),
|
| 45 |
+
velocity=velocity,
|
| 46 |
+
channel=int(event.channel),
|
| 47 |
+
time=delta_ticks,
|
| 48 |
+
)
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
track.append(mido.MetaMessage("end_of_track", time=0))
|
| 52 |
+
buffer = BytesIO()
|
| 53 |
+
midi_file.save(file=buffer)
|
| 54 |
+
return buffer.getvalue()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def write_phrase_payload_midi(payload: PhrasePayload, path: Path) -> None:
|
| 58 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
path.write_bytes(phrase_payload_to_midi_bytes(payload))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def midi_files_to_zip_bytes(files: list[tuple[str, bytes]]) -> bytes:
|
| 63 |
+
buffer = BytesIO()
|
| 64 |
+
with ZipFile(buffer, mode="w", compression=ZIP_DEFLATED) as archive:
|
| 65 |
+
for file_name, content in files:
|
| 66 |
+
archive.writestr(file_name, content)
|
| 67 |
+
return buffer.getvalue()
|
backend/app/schemas.py
CHANGED
|
@@ -178,6 +178,46 @@ class SessionHistoryResponse(BaseModel):
|
|
| 178 |
items: list[HistoryItem]
|
| 179 |
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
class MemoryPhraseItem(BaseModel):
|
| 182 |
slot: int = Field(ge=1)
|
| 183 |
source: MemoryPhraseSource
|
|
|
|
| 178 |
items: list[HistoryItem]
|
| 179 |
|
| 180 |
|
| 181 |
+
class SessionMidiExportFile(BaseModel):
|
| 182 |
+
kind: Literal["input", "generated"]
|
| 183 |
+
request_id: str
|
| 184 |
+
file_name: str
|
| 185 |
+
path: str
|
| 186 |
+
event_count: int = Field(ge=0)
|
| 187 |
+
note_count: int = Field(ge=0)
|
| 188 |
+
duration_seconds: float = Field(ge=0.0)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class SaveSessionMidiResponse(BaseModel):
|
| 192 |
+
session_id: str
|
| 193 |
+
exported_at: str
|
| 194 |
+
directory: str
|
| 195 |
+
file_count: int = Field(ge=0)
|
| 196 |
+
input_file_count: int = Field(ge=0)
|
| 197 |
+
generated_file_count: int = Field(ge=0)
|
| 198 |
+
files: list[SessionMidiExportFile]
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class SessionMidiDownloadFile(BaseModel):
|
| 202 |
+
kind: Literal["input", "generated"]
|
| 203 |
+
request_id: str
|
| 204 |
+
file_name: str
|
| 205 |
+
event_count: int = Field(ge=0)
|
| 206 |
+
note_count: int = Field(ge=0)
|
| 207 |
+
duration_seconds: float = Field(ge=0.0)
|
| 208 |
+
content_base64: str
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class DownloadSessionMidiResponse(BaseModel):
|
| 212 |
+
session_id: str
|
| 213 |
+
exported_at: str
|
| 214 |
+
folder_name: str
|
| 215 |
+
file_count: int = Field(ge=0)
|
| 216 |
+
input_file_count: int = Field(ge=0)
|
| 217 |
+
generated_file_count: int = Field(ge=0)
|
| 218 |
+
files: list[SessionMidiDownloadFile]
|
| 219 |
+
|
| 220 |
+
|
| 221 |
class MemoryPhraseItem(BaseModel):
|
| 222 |
slot: int = Field(ge=1)
|
| 223 |
source: MemoryPhraseSource
|
backend/app/session_manager.py
CHANGED
|
@@ -3,15 +3,22 @@ from __future__ import annotations
|
|
| 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, MidiImportError, NoContinuationAvailable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from .schemas import (
|
| 11 |
ContinueRequest,
|
| 12 |
ContinueResponse,
|
| 13 |
CreateSessionRequest,
|
| 14 |
CreateSessionResponse,
|
|
|
|
| 15 |
GeneratePhraseResponse,
|
| 16 |
GenerationConstraintsStatus,
|
| 17 |
GenerationTraceStep,
|
|
@@ -23,8 +30,11 @@ from .schemas import (
|
|
| 23 |
OpenSessionResponse,
|
| 24 |
PhrasePayload,
|
| 25 |
ResetSessionResponse,
|
|
|
|
| 26 |
SessionConfiguration,
|
| 27 |
SessionHistoryResponse,
|
|
|
|
|
|
|
| 28 |
SessionMemoryResponse,
|
| 29 |
SessionMemorySummary,
|
| 30 |
UpdateSessionNameRequest,
|
|
@@ -47,6 +57,15 @@ def utc_now_iso() -> str:
|
|
| 47 |
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
| 48 |
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
@dataclass
|
| 51 |
class SessionState:
|
| 52 |
session_id: str
|
|
@@ -68,10 +87,12 @@ class SessionManager:
|
|
| 68 |
storage: PhraseStorage,
|
| 69 |
seed_midi_file: Path | None = None,
|
| 70 |
seed_midi_folder: Path | None = None,
|
|
|
|
| 71 |
) -> None:
|
| 72 |
self.storage = storage
|
| 73 |
self.seed_midi_file = seed_midi_file
|
| 74 |
self.seed_midi_folder = seed_midi_folder
|
|
|
|
| 75 |
self._sessions: dict[str, SessionState] = {}
|
| 76 |
self._lock = threading.RLock()
|
| 77 |
|
|
@@ -528,6 +549,138 @@ class SessionManager:
|
|
| 528 |
]
|
| 529 |
return SessionHistoryResponse(session_id=session_id, items=items)
|
| 530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
def get_memory(self, session_id: str, owner_user_id: str | None) -> SessionMemoryResponse:
|
| 532 |
state = self._require_session(session_id, owner_user_id)
|
| 533 |
return self._memory_response(
|
|
|
|
| 3 |
from dataclasses import dataclass
|
| 4 |
from datetime import UTC, datetime
|
| 5 |
from pathlib import Path
|
| 6 |
+
import base64
|
| 7 |
import threading
|
| 8 |
import uuid
|
| 9 |
|
| 10 |
from .continuator_adapter import ContinuatorSessionEngine, MidiImportError, NoContinuationAvailable
|
| 11 |
+
from .midi_export import (
|
| 12 |
+
midi_files_to_zip_bytes,
|
| 13 |
+
phrase_payload_to_midi_bytes,
|
| 14 |
+
write_phrase_payload_midi,
|
| 15 |
+
)
|
| 16 |
from .schemas import (
|
| 17 |
ContinueRequest,
|
| 18 |
ContinueResponse,
|
| 19 |
CreateSessionRequest,
|
| 20 |
CreateSessionResponse,
|
| 21 |
+
DownloadSessionMidiResponse,
|
| 22 |
GeneratePhraseResponse,
|
| 23 |
GenerationConstraintsStatus,
|
| 24 |
GenerationTraceStep,
|
|
|
|
| 30 |
OpenSessionResponse,
|
| 31 |
PhrasePayload,
|
| 32 |
ResetSessionResponse,
|
| 33 |
+
SaveSessionMidiResponse,
|
| 34 |
SessionConfiguration,
|
| 35 |
SessionHistoryResponse,
|
| 36 |
+
SessionMidiDownloadFile,
|
| 37 |
+
SessionMidiExportFile,
|
| 38 |
SessionMemoryResponse,
|
| 39 |
SessionMemorySummary,
|
| 40 |
UpdateSessionNameRequest,
|
|
|
|
| 57 |
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
| 58 |
|
| 59 |
|
| 60 |
+
def safe_export_path_part(value: object, fallback: str) -> str:
|
| 61 |
+
normalized = "".join(
|
| 62 |
+
char if char.isalnum() or char in {"-", "_"} else "-"
|
| 63 |
+
for char in str(value or "")
|
| 64 |
+
)
|
| 65 |
+
normalized = "-".join(part for part in normalized.split("-") if part)
|
| 66 |
+
return (normalized[:80] or fallback).strip("-_") or fallback
|
| 67 |
+
|
| 68 |
+
|
| 69 |
@dataclass
|
| 70 |
class SessionState:
|
| 71 |
session_id: str
|
|
|
|
| 87 |
storage: PhraseStorage,
|
| 88 |
seed_midi_file: Path | None = None,
|
| 89 |
seed_midi_folder: Path | None = None,
|
| 90 |
+
session_export_dir: Path | None = None,
|
| 91 |
) -> None:
|
| 92 |
self.storage = storage
|
| 93 |
self.seed_midi_file = seed_midi_file
|
| 94 |
self.seed_midi_folder = seed_midi_folder
|
| 95 |
+
self.session_export_dir = session_export_dir or Path("session_midi_exports")
|
| 96 |
self._sessions: dict[str, SessionState] = {}
|
| 97 |
self._lock = threading.RLock()
|
| 98 |
|
|
|
|
| 549 |
]
|
| 550 |
return SessionHistoryResponse(session_id=session_id, items=items)
|
| 551 |
|
| 552 |
+
def save_session_midi(
|
| 553 |
+
self,
|
| 554 |
+
session_id: str,
|
| 555 |
+
owner_user_id: str | None,
|
| 556 |
+
) -> SaveSessionMidiResponse:
|
| 557 |
+
if not self.storage.session_exists(session_id, owner_user_id, allow_guest=True):
|
| 558 |
+
raise UnknownSessionError(session_id)
|
| 559 |
+
|
| 560 |
+
rows = self.storage.get_played_and_generated_phrases(session_id)
|
| 561 |
+
if not rows:
|
| 562 |
+
raise ValueError("No played or generated phrases are available to save yet.")
|
| 563 |
+
|
| 564 |
+
exported_at = utc_now_iso()
|
| 565 |
+
folder_name = "-".join(
|
| 566 |
+
[
|
| 567 |
+
safe_export_path_part(session_id[:12], "session"),
|
| 568 |
+
safe_export_path_part(exported_at, "export"),
|
| 569 |
+
uuid.uuid4().hex[:8],
|
| 570 |
+
]
|
| 571 |
+
)
|
| 572 |
+
export_dir = self.session_export_dir / folder_name
|
| 573 |
+
counts = {"input": 0, "generated": 0}
|
| 574 |
+
files: list[SessionMidiExportFile] = []
|
| 575 |
+
|
| 576 |
+
for index, row in enumerate(rows, start=1):
|
| 577 |
+
kind = str(row["kind"])
|
| 578 |
+
if kind not in counts:
|
| 579 |
+
continue
|
| 580 |
+
payload = PhrasePayload.model_validate(row["payload"])
|
| 581 |
+
counts[kind] += 1
|
| 582 |
+
created_at = safe_export_path_part(row["created_at"], "time")
|
| 583 |
+
request_id = safe_export_path_part(row["request_id"], "request")
|
| 584 |
+
file_name = f"{index:04d}_{kind}_{created_at}_{request_id[:12]}.mid"
|
| 585 |
+
path = export_dir / file_name
|
| 586 |
+
write_phrase_payload_midi(payload, path)
|
| 587 |
+
files.append(
|
| 588 |
+
SessionMidiExportFile(
|
| 589 |
+
kind=kind,
|
| 590 |
+
request_id=str(row["request_id"]),
|
| 591 |
+
file_name=file_name,
|
| 592 |
+
path=str(path.resolve()),
|
| 593 |
+
event_count=payload.event_count,
|
| 594 |
+
note_count=payload.note_count,
|
| 595 |
+
duration_seconds=payload.duration_seconds,
|
| 596 |
+
)
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
if not files:
|
| 600 |
+
raise ValueError("No playable phrases are available to save yet.")
|
| 601 |
+
|
| 602 |
+
return SaveSessionMidiResponse(
|
| 603 |
+
session_id=session_id,
|
| 604 |
+
exported_at=exported_at,
|
| 605 |
+
directory=str(export_dir.resolve()),
|
| 606 |
+
file_count=len(files),
|
| 607 |
+
input_file_count=counts["input"],
|
| 608 |
+
generated_file_count=counts["generated"],
|
| 609 |
+
files=files,
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
def download_session_midi(
|
| 613 |
+
self,
|
| 614 |
+
session_id: str,
|
| 615 |
+
owner_user_id: str | None,
|
| 616 |
+
) -> DownloadSessionMidiResponse:
|
| 617 |
+
if not self.storage.session_exists(session_id, owner_user_id, allow_guest=True):
|
| 618 |
+
raise UnknownSessionError(session_id)
|
| 619 |
+
|
| 620 |
+
rows = self.storage.get_played_and_generated_phrases(session_id)
|
| 621 |
+
if not rows:
|
| 622 |
+
raise ValueError("No played or generated phrases are available to save yet.")
|
| 623 |
+
|
| 624 |
+
exported_at = utc_now_iso()
|
| 625 |
+
folder_name = "-".join(
|
| 626 |
+
[
|
| 627 |
+
"continuator-session",
|
| 628 |
+
safe_export_path_part(session_id[:12], "session"),
|
| 629 |
+
safe_export_path_part(exported_at, "export"),
|
| 630 |
+
uuid.uuid4().hex[:8],
|
| 631 |
+
]
|
| 632 |
+
)
|
| 633 |
+
counts = {"input": 0, "generated": 0}
|
| 634 |
+
files: list[SessionMidiDownloadFile] = []
|
| 635 |
+
|
| 636 |
+
for index, row in enumerate(rows, start=1):
|
| 637 |
+
kind = str(row["kind"])
|
| 638 |
+
if kind not in counts:
|
| 639 |
+
continue
|
| 640 |
+
payload = PhrasePayload.model_validate(row["payload"])
|
| 641 |
+
counts[kind] += 1
|
| 642 |
+
created_at = safe_export_path_part(row["created_at"], "time")
|
| 643 |
+
request_id = safe_export_path_part(row["request_id"], "request")
|
| 644 |
+
file_name = f"{index:04d}_{kind}_{created_at}_{request_id[:12]}.mid"
|
| 645 |
+
midi_bytes = phrase_payload_to_midi_bytes(payload)
|
| 646 |
+
files.append(
|
| 647 |
+
SessionMidiDownloadFile(
|
| 648 |
+
kind=kind,
|
| 649 |
+
request_id=str(row["request_id"]),
|
| 650 |
+
file_name=file_name,
|
| 651 |
+
event_count=payload.event_count,
|
| 652 |
+
note_count=payload.note_count,
|
| 653 |
+
duration_seconds=payload.duration_seconds,
|
| 654 |
+
content_base64=base64.b64encode(midi_bytes).decode("ascii"),
|
| 655 |
+
)
|
| 656 |
+
)
|
| 657 |
+
|
| 658 |
+
if not files:
|
| 659 |
+
raise ValueError("No playable phrases are available to save yet.")
|
| 660 |
+
|
| 661 |
+
return DownloadSessionMidiResponse(
|
| 662 |
+
session_id=session_id,
|
| 663 |
+
exported_at=exported_at,
|
| 664 |
+
folder_name=folder_name,
|
| 665 |
+
file_count=len(files),
|
| 666 |
+
input_file_count=counts["input"],
|
| 667 |
+
generated_file_count=counts["generated"],
|
| 668 |
+
files=files,
|
| 669 |
+
)
|
| 670 |
+
|
| 671 |
+
def session_midi_zip(
|
| 672 |
+
self,
|
| 673 |
+
session_id: str,
|
| 674 |
+
owner_user_id: str | None,
|
| 675 |
+
) -> tuple[str, bytes, DownloadSessionMidiResponse]:
|
| 676 |
+
payload = self.download_session_midi(session_id, owner_user_id)
|
| 677 |
+
files = [
|
| 678 |
+
(file.file_name, base64.b64decode(file.content_base64))
|
| 679 |
+
for file in payload.files
|
| 680 |
+
]
|
| 681 |
+
archive_name = f"{payload.folder_name}.zip"
|
| 682 |
+
return archive_name, midi_files_to_zip_bytes(files), payload
|
| 683 |
+
|
| 684 |
def get_memory(self, session_id: str, owner_user_id: str | None) -> SessionMemoryResponse:
|
| 685 |
state = self._require_session(session_id, owner_user_id)
|
| 686 |
return self._memory_response(
|
backend/app/storage.py
CHANGED
|
@@ -605,3 +605,56 @@ class PhraseStorage:
|
|
| 605 |
}
|
| 606 |
for row in rows
|
| 607 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
}
|
| 606 |
for row in rows
|
| 607 |
]
|
| 608 |
+
|
| 609 |
+
def get_played_and_generated_phrases(self, session_id: str) -> list[dict[str, object]]:
|
| 610 |
+
with self._lock, self._connect() as connection:
|
| 611 |
+
rows = connection.execute(
|
| 612 |
+
"""
|
| 613 |
+
SELECT
|
| 614 |
+
phrases.id,
|
| 615 |
+
phrases.request_id,
|
| 616 |
+
phrases.kind,
|
| 617 |
+
phrases.created_at,
|
| 618 |
+
phrases.event_count,
|
| 619 |
+
phrases.note_count,
|
| 620 |
+
phrases.duration_seconds,
|
| 621 |
+
phrases.payload_json
|
| 622 |
+
FROM phrases
|
| 623 |
+
WHERE phrases.session_id = ?
|
| 624 |
+
AND phrases.event_count > 0
|
| 625 |
+
AND (
|
| 626 |
+
phrases.kind = 'generated'
|
| 627 |
+
OR (
|
| 628 |
+
phrases.kind = 'input'
|
| 629 |
+
AND EXISTS (
|
| 630 |
+
SELECT 1
|
| 631 |
+
FROM phrases AS generated
|
| 632 |
+
WHERE generated.session_id = phrases.session_id
|
| 633 |
+
AND generated.request_id = phrases.request_id
|
| 634 |
+
AND generated.kind = 'generated'
|
| 635 |
+
AND generated.event_count > 0
|
| 636 |
+
)
|
| 637 |
+
)
|
| 638 |
+
)
|
| 639 |
+
ORDER BY
|
| 640 |
+
phrases.created_at ASC,
|
| 641 |
+
phrases.request_id ASC,
|
| 642 |
+
CASE phrases.kind WHEN 'input' THEN 0 ELSE 1 END,
|
| 643 |
+
phrases.id ASC
|
| 644 |
+
""",
|
| 645 |
+
(session_id,),
|
| 646 |
+
).fetchall()
|
| 647 |
+
|
| 648 |
+
return [
|
| 649 |
+
{
|
| 650 |
+
"id": row["id"],
|
| 651 |
+
"request_id": row["request_id"],
|
| 652 |
+
"kind": row["kind"],
|
| 653 |
+
"created_at": row["created_at"],
|
| 654 |
+
"event_count": row["event_count"],
|
| 655 |
+
"note_count": row["note_count"],
|
| 656 |
+
"duration_seconds": row["duration_seconds"],
|
| 657 |
+
"payload": json.loads(row["payload_json"]),
|
| 658 |
+
}
|
| 659 |
+
for row in rows
|
| 660 |
+
]
|
frontend/app.js
CHANGED
|
@@ -53,6 +53,9 @@ const FAUST_CLAVIER_DSP_URL = "/assets/faust/continuator-clavier.dsp";
|
|
| 53 |
const FAUST_CUSTOM_TEMPLATE_DSP_URL = "/assets/faust/custom-poly-template.dsp";
|
| 54 |
const PHRASE_TIMEOUT_STORAGE_KEY = "continuator.phrase.timeout.ms";
|
| 55 |
const MIDI_INPUT_MONITOR_STORAGE_KEY = "continuator.midi.input.monitor";
|
|
|
|
|
|
|
|
|
|
| 56 |
const FAUST_CUSTOM_SOURCE_STORAGE_KEY = "continuator.faust.custom.source";
|
| 57 |
const FAUST_CUSTOM_VALUES_STORAGE_KEY = "continuator.faust.custom.values";
|
| 58 |
const PLAYBACK_PREFERENCE_STORAGE_PREFIX = "continuator.playback.preference";
|
|
@@ -151,6 +154,8 @@ const elements = {
|
|
| 151 |
readyOutputStep: document.querySelector("#ready-output-step"),
|
| 152 |
rendererHealth: document.querySelector("#renderer-health"),
|
| 153 |
sessionSaveNote: document.querySelector("#session-save-note"),
|
|
|
|
|
|
|
| 154 |
timingReadout: document.querySelector("#timing-readout"),
|
| 155 |
timelineCaptured: document.querySelector("#timeline-captured"),
|
| 156 |
timelineGenerated: document.querySelector("#timeline-generated"),
|
|
@@ -210,6 +215,7 @@ const elements = {
|
|
| 210 |
panicButton: document.querySelector("#panic-button"),
|
| 211 |
createSessionButton: document.querySelector("#create-session-button"),
|
| 212 |
resetSessionButton: document.querySelector("#reset-session-button"),
|
|
|
|
| 213 |
applySettingsButton: document.querySelector("#apply-settings-button"),
|
| 214 |
importMidiFilesButton: document.querySelector("#import-midi-files-button"),
|
| 215 |
importMidiFolderButton: document.querySelector("#import-midi-folder-button"),
|
|
@@ -241,6 +247,8 @@ const state = {
|
|
| 241 |
authUser: null,
|
| 242 |
sessionId: null,
|
| 243 |
sessionIsOwned: false,
|
|
|
|
|
|
|
| 244 |
selectedInputLabel: "No MIDI input selected",
|
| 245 |
lastMidiEventLabel: "None yet",
|
| 246 |
readmeOpen: false,
|
|
@@ -536,6 +544,85 @@ function safeLocalStorageSet(key, value) {
|
|
| 536 |
}
|
| 537 |
}
|
| 538 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
function loadStoredInputMonitorPreference() {
|
| 540 |
return safeLocalStorageGet(MIDI_INPUT_MONITOR_STORAGE_KEY) === "true";
|
| 541 |
}
|
|
@@ -1867,6 +1954,121 @@ function renderPerformanceState() {
|
|
| 1867 |
renderTimingReadout();
|
| 1868 |
}
|
| 1869 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1870 |
function stopPhraseGapCountdown() {
|
| 1871 |
if (state.phraseGapAnimationFrameId) {
|
| 1872 |
window.cancelAnimationFrame(state.phraseGapAnimationFrameId);
|
|
@@ -2886,8 +3088,23 @@ function syncSettingsControls(configuration) {
|
|
| 2886 |
|
| 2887 |
function updateSessionActionState() {
|
| 2888 |
const hasPendingSettings = hasPendingSessionSettings();
|
|
|
|
| 2889 |
elements.createSessionButton.disabled = false;
|
| 2890 |
elements.resetSessionButton.disabled = !state.sessionId;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2891 |
elements.showGraphButton.disabled = !state.sessionId || state.graphLoading;
|
| 2892 |
elements.showGraphButton.classList.toggle("is-pending", state.graphLoading);
|
| 2893 |
elements.showGraphButton.textContent = state.graphLoading
|
|
@@ -2985,6 +3202,7 @@ function clearCurrentSessionState(message = null) {
|
|
| 2985 |
state.sessionConfiguration = null;
|
| 2986 |
state.historyItems = [];
|
| 2987 |
state.memoryItems = [];
|
|
|
|
| 2988 |
elements.sessionId.textContent = "Open or create a session";
|
| 2989 |
setGraphOpen(false);
|
| 2990 |
clearPhraseBuffers();
|
|
@@ -4390,6 +4608,7 @@ async function useSessionPayload(payload, { owned = Boolean(state.authUser) } =
|
|
| 4390 |
state.sessionId = payload.session_id;
|
| 4391 |
state.sessionIsOwned = owned;
|
| 4392 |
state.sessionConfiguration = payload.configuration;
|
|
|
|
| 4393 |
elements.sessionId.textContent = payload.session_id;
|
| 4394 |
syncSettingsControls(payload.configuration);
|
| 4395 |
await restoreSessionPreferences(payload.configuration);
|
|
@@ -4478,6 +4697,54 @@ async function resetSession() {
|
|
| 4478 |
await refreshSavedSessions();
|
| 4479 |
}
|
| 4480 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4481 |
async function applyCurrentSessionSettings() {
|
| 4482 |
if (!state.sessionId) {
|
| 4483 |
setPhraseMessage("Create a session before applying settings.", true);
|
|
@@ -4706,6 +4973,14 @@ function defaultMidiImportMessage(payload) {
|
|
| 4706 |
return message;
|
| 4707 |
}
|
| 4708 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4709 |
function defaultMemoryGenerationMessage(payload, requestedNoteCount) {
|
| 4710 |
return (
|
| 4711 |
payload.status_message ||
|
|
@@ -6566,6 +6841,30 @@ function bindEvents() {
|
|
| 6566 |
}
|
| 6567 |
});
|
| 6568 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6569 |
elements.connectMidiButton.addEventListener("click", async () => {
|
| 6570 |
try {
|
| 6571 |
await connectMidi();
|
|
@@ -7038,6 +7337,7 @@ async function initialize() {
|
|
| 7038 |
}
|
| 7039 |
initializePhraseTimeoutSetting();
|
| 7040 |
initializeInputMonitorPreference();
|
|
|
|
| 7041 |
populatePlaybackChoices();
|
| 7042 |
syncAuthUI();
|
| 7043 |
renderSavedSessions([]);
|
|
|
|
| 53 |
const FAUST_CUSTOM_TEMPLATE_DSP_URL = "/assets/faust/custom-poly-template.dsp";
|
| 54 |
const PHRASE_TIMEOUT_STORAGE_KEY = "continuator.phrase.timeout.ms";
|
| 55 |
const MIDI_INPUT_MONITOR_STORAGE_KEY = "continuator.midi.input.monitor";
|
| 56 |
+
const MIDI_EXPORT_FILE_DB_NAME = "continuator-midi-zip-export";
|
| 57 |
+
const MIDI_EXPORT_FILE_STORE_NAME = "file-handles";
|
| 58 |
+
const MIDI_EXPORT_FILE_HANDLE_KEY = "session-midi-export-zip";
|
| 59 |
const FAUST_CUSTOM_SOURCE_STORAGE_KEY = "continuator.faust.custom.source";
|
| 60 |
const FAUST_CUSTOM_VALUES_STORAGE_KEY = "continuator.faust.custom.values";
|
| 61 |
const PLAYBACK_PREFERENCE_STORAGE_PREFIX = "continuator.playback.preference";
|
|
|
|
| 154 |
readyOutputStep: document.querySelector("#ready-output-step"),
|
| 155 |
rendererHealth: document.querySelector("#renderer-health"),
|
| 156 |
sessionSaveNote: document.querySelector("#session-save-note"),
|
| 157 |
+
sessionMidiSaveResult: document.querySelector("#session-midi-save-result"),
|
| 158 |
+
rememberMidiExportFileToggle: document.querySelector("#remember-midi-export-file-toggle"),
|
| 159 |
timingReadout: document.querySelector("#timing-readout"),
|
| 160 |
timelineCaptured: document.querySelector("#timeline-captured"),
|
| 161 |
timelineGenerated: document.querySelector("#timeline-generated"),
|
|
|
|
| 215 |
panicButton: document.querySelector("#panic-button"),
|
| 216 |
createSessionButton: document.querySelector("#create-session-button"),
|
| 217 |
resetSessionButton: document.querySelector("#reset-session-button"),
|
| 218 |
+
saveSessionMidiButton: document.querySelector("#save-session-midi-button"),
|
| 219 |
applySettingsButton: document.querySelector("#apply-settings-button"),
|
| 220 |
importMidiFilesButton: document.querySelector("#import-midi-files-button"),
|
| 221 |
importMidiFolderButton: document.querySelector("#import-midi-folder-button"),
|
|
|
|
| 247 |
authUser: null,
|
| 248 |
sessionId: null,
|
| 249 |
sessionIsOwned: false,
|
| 250 |
+
sessionMidiSaving: false,
|
| 251 |
+
midiExportFileHandle: null,
|
| 252 |
selectedInputLabel: "No MIDI input selected",
|
| 253 |
lastMidiEventLabel: "None yet",
|
| 254 |
readmeOpen: false,
|
|
|
|
| 544 |
}
|
| 545 |
}
|
| 546 |
|
| 547 |
+
function midiExportZipSaveSupported() {
|
| 548 |
+
return typeof window.showSaveFilePicker === "function";
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
function midiExportZipRememberSupported() {
|
| 552 |
+
return midiExportZipSaveSupported() && typeof window.indexedDB !== "undefined";
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
function openMidiExportFileDb() {
|
| 556 |
+
return new Promise((resolve, reject) => {
|
| 557 |
+
if (!window.indexedDB) {
|
| 558 |
+
reject(new Error("This browser cannot remember export files."));
|
| 559 |
+
return;
|
| 560 |
+
}
|
| 561 |
+
const request = window.indexedDB.open(MIDI_EXPORT_FILE_DB_NAME, 1);
|
| 562 |
+
request.onupgradeneeded = () => {
|
| 563 |
+
request.result.createObjectStore(MIDI_EXPORT_FILE_STORE_NAME);
|
| 564 |
+
};
|
| 565 |
+
request.onsuccess = () => resolve(request.result);
|
| 566 |
+
request.onerror = () => reject(request.error || new Error("Could not open export file storage."));
|
| 567 |
+
});
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
async function withMidiExportFileStore(mode, callback) {
|
| 571 |
+
const db = await openMidiExportFileDb();
|
| 572 |
+
try {
|
| 573 |
+
return await new Promise((resolve, reject) => {
|
| 574 |
+
const transaction = db.transaction(MIDI_EXPORT_FILE_STORE_NAME, mode);
|
| 575 |
+
const store = transaction.objectStore(MIDI_EXPORT_FILE_STORE_NAME);
|
| 576 |
+
const request = callback(store);
|
| 577 |
+
request.onsuccess = () => resolve(request.result);
|
| 578 |
+
request.onerror = () => reject(request.error || new Error("Export file storage failed."));
|
| 579 |
+
});
|
| 580 |
+
} finally {
|
| 581 |
+
db.close();
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
function readStoredMidiExportFileHandle() {
|
| 586 |
+
return withMidiExportFileStore("readonly", (store) =>
|
| 587 |
+
store.get(MIDI_EXPORT_FILE_HANDLE_KEY),
|
| 588 |
+
);
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
function writeStoredMidiExportFileHandle(handle) {
|
| 592 |
+
return withMidiExportFileStore("readwrite", (store) =>
|
| 593 |
+
store.put(handle, MIDI_EXPORT_FILE_HANDLE_KEY),
|
| 594 |
+
);
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
function clearStoredMidiExportFileHandle() {
|
| 598 |
+
return withMidiExportFileStore("readwrite", (store) =>
|
| 599 |
+
store.delete(MIDI_EXPORT_FILE_HANDLE_KEY),
|
| 600 |
+
);
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
async function initializeMidiExportFilePreference() {
|
| 604 |
+
if (!elements.rememberMidiExportFileToggle) {
|
| 605 |
+
return;
|
| 606 |
+
}
|
| 607 |
+
const supported = midiExportZipRememberSupported();
|
| 608 |
+
elements.rememberMidiExportFileToggle.disabled = !supported;
|
| 609 |
+
if (!supported) {
|
| 610 |
+
elements.rememberMidiExportFileToggle.title =
|
| 611 |
+
"Remembering export files is not available in this browser.";
|
| 612 |
+
return;
|
| 613 |
+
}
|
| 614 |
+
try {
|
| 615 |
+
const handle = await readStoredMidiExportFileHandle();
|
| 616 |
+
if (handle) {
|
| 617 |
+
state.midiExportFileHandle = handle;
|
| 618 |
+
elements.rememberMidiExportFileToggle.checked = true;
|
| 619 |
+
}
|
| 620 |
+
} catch {
|
| 621 |
+
state.midiExportFileHandle = null;
|
| 622 |
+
elements.rememberMidiExportFileToggle.checked = false;
|
| 623 |
+
}
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
function loadStoredInputMonitorPreference() {
|
| 627 |
return safeLocalStorageGet(MIDI_INPUT_MONITOR_STORAGE_KEY) === "true";
|
| 628 |
}
|
|
|
|
| 1954 |
renderTimingReadout();
|
| 1955 |
}
|
| 1956 |
|
| 1957 |
+
function setSessionMidiSaveResult(message = "", isError = false) {
|
| 1958 |
+
if (!elements.sessionMidiSaveResult) {
|
| 1959 |
+
return;
|
| 1960 |
+
}
|
| 1961 |
+
const normalizedMessage = String(message || "").trim();
|
| 1962 |
+
elements.sessionMidiSaveResult.hidden = !normalizedMessage;
|
| 1963 |
+
elements.sessionMidiSaveResult.textContent = normalizedMessage;
|
| 1964 |
+
elements.sessionMidiSaveResult.classList.toggle("is-error", Boolean(isError));
|
| 1965 |
+
}
|
| 1966 |
+
|
| 1967 |
+
async function ensureFileWritePermission(handle) {
|
| 1968 |
+
const options = { mode: "readwrite" };
|
| 1969 |
+
if ((await handle.queryPermission(options)) === "granted") {
|
| 1970 |
+
return true;
|
| 1971 |
+
}
|
| 1972 |
+
return (await handle.requestPermission(options)) === "granted";
|
| 1973 |
+
}
|
| 1974 |
+
|
| 1975 |
+
function fileNameFromContentDisposition(value) {
|
| 1976 |
+
const match = String(value || "").match(/filename="([^"]+)"/i);
|
| 1977 |
+
return match?.[1] || `continuator-session-${Date.now()}.zip`;
|
| 1978 |
+
}
|
| 1979 |
+
|
| 1980 |
+
async function requestSessionMidiZip() {
|
| 1981 |
+
const response = await fetch(`/api/sessions/${state.sessionId}/midi.zip`);
|
| 1982 |
+
const blob = await response.blob();
|
| 1983 |
+
if (!response.ok) {
|
| 1984 |
+
let detail = `MIDI export failed (${response.status}).`;
|
| 1985 |
+
try {
|
| 1986 |
+
const text = await blob.text();
|
| 1987 |
+
const payload = JSON.parse(text);
|
| 1988 |
+
if (typeof payload?.detail === "string") {
|
| 1989 |
+
detail = payload.detail;
|
| 1990 |
+
}
|
| 1991 |
+
} catch {
|
| 1992 |
+
// Keep the default error.
|
| 1993 |
+
}
|
| 1994 |
+
throw new Error(detail);
|
| 1995 |
+
}
|
| 1996 |
+
|
| 1997 |
+
return {
|
| 1998 |
+
blob,
|
| 1999 |
+
archive_file_name: fileNameFromContentDisposition(
|
| 2000 |
+
response.headers.get("Content-Disposition"),
|
| 2001 |
+
),
|
| 2002 |
+
file_count: Number(response.headers.get("X-Midi-File-Count") || 0),
|
| 2003 |
+
input_file_count: Number(response.headers.get("X-Midi-Input-File-Count") || 0),
|
| 2004 |
+
generated_file_count: Number(response.headers.get("X-Midi-Generated-File-Count") || 0),
|
| 2005 |
+
};
|
| 2006 |
+
}
|
| 2007 |
+
|
| 2008 |
+
async function chooseMidiExportZipFileHandle(fileName) {
|
| 2009 |
+
const rememberFile = elements.rememberMidiExportFileToggle?.checked;
|
| 2010 |
+
if (rememberFile && state.midiExportFileHandle) {
|
| 2011 |
+
if (await ensureFileWritePermission(state.midiExportFileHandle)) {
|
| 2012 |
+
return state.midiExportFileHandle;
|
| 2013 |
+
}
|
| 2014 |
+
}
|
| 2015 |
+
|
| 2016 |
+
const pickerOptions = {
|
| 2017 |
+
id: "continuator-session-midi-zip-export",
|
| 2018 |
+
suggestedName: fileName || `continuator-session-${Date.now()}.zip`,
|
| 2019 |
+
startIn: "downloads",
|
| 2020 |
+
types: [
|
| 2021 |
+
{
|
| 2022 |
+
description: "ZIP archive",
|
| 2023 |
+
accept: {
|
| 2024 |
+
"application/zip": [".zip"],
|
| 2025 |
+
},
|
| 2026 |
+
},
|
| 2027 |
+
],
|
| 2028 |
+
};
|
| 2029 |
+
let handle = null;
|
| 2030 |
+
try {
|
| 2031 |
+
handle = await window.showSaveFilePicker(pickerOptions);
|
| 2032 |
+
} catch (error) {
|
| 2033 |
+
if (error?.name === "TypeError") {
|
| 2034 |
+
handle = await window.showSaveFilePicker({
|
| 2035 |
+
suggestedName: pickerOptions.suggestedName,
|
| 2036 |
+
types: pickerOptions.types,
|
| 2037 |
+
});
|
| 2038 |
+
} else {
|
| 2039 |
+
throw error;
|
| 2040 |
+
}
|
| 2041 |
+
}
|
| 2042 |
+
if (!(await ensureFileWritePermission(handle))) {
|
| 2043 |
+
throw new Error("Permission to write that ZIP file was not granted.");
|
| 2044 |
+
}
|
| 2045 |
+
|
| 2046 |
+
if (rememberFile && midiExportZipRememberSupported()) {
|
| 2047 |
+
state.midiExportFileHandle = handle;
|
| 2048 |
+
await writeStoredMidiExportFileHandle(handle);
|
| 2049 |
+
}
|
| 2050 |
+
return handle;
|
| 2051 |
+
}
|
| 2052 |
+
|
| 2053 |
+
async function writeZipBlobToFileHandle(blob, handle) {
|
| 2054 |
+
const writable = await handle.createWritable();
|
| 2055 |
+
await writable.write(blob);
|
| 2056 |
+
await writable.close();
|
| 2057 |
+
return handle.name || "selected ZIP file";
|
| 2058 |
+
}
|
| 2059 |
+
|
| 2060 |
+
function downloadZipBlob(blob, fileName) {
|
| 2061 |
+
const url = URL.createObjectURL(blob);
|
| 2062 |
+
const link = document.createElement("a");
|
| 2063 |
+
link.href = url;
|
| 2064 |
+
link.download = fileName || `continuator-session-${Date.now()}.zip`;
|
| 2065 |
+
document.body.append(link);
|
| 2066 |
+
link.click();
|
| 2067 |
+
link.remove();
|
| 2068 |
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
| 2069 |
+
return link.download;
|
| 2070 |
+
}
|
| 2071 |
+
|
| 2072 |
function stopPhraseGapCountdown() {
|
| 2073 |
if (state.phraseGapAnimationFrameId) {
|
| 2074 |
window.cancelAnimationFrame(state.phraseGapAnimationFrameId);
|
|
|
|
| 3088 |
|
| 3089 |
function updateSessionActionState() {
|
| 3090 |
const hasPendingSettings = hasPendingSessionSettings();
|
| 3091 |
+
const canRememberExportFile = midiExportZipRememberSupported();
|
| 3092 |
elements.createSessionButton.disabled = false;
|
| 3093 |
elements.resetSessionButton.disabled = !state.sessionId;
|
| 3094 |
+
elements.saveSessionMidiButton.disabled = !state.sessionId || state.sessionMidiSaving;
|
| 3095 |
+
elements.saveSessionMidiButton.classList.toggle("is-pending", state.sessionMidiSaving);
|
| 3096 |
+
elements.saveSessionMidiButton.textContent = state.sessionMidiSaving
|
| 3097 |
+
? "Saving MIDI..."
|
| 3098 |
+
: "Save Session MIDI";
|
| 3099 |
+
elements.rememberMidiExportFileToggle.disabled =
|
| 3100 |
+
!canRememberExportFile || state.sessionMidiSaving;
|
| 3101 |
+
if (!canRememberExportFile) {
|
| 3102 |
+
elements.rememberMidiExportFileToggle.checked = false;
|
| 3103 |
+
elements.rememberMidiExportFileToggle.title =
|
| 3104 |
+
"Remembering export files is not available in this browser.";
|
| 3105 |
+
} else {
|
| 3106 |
+
elements.rememberMidiExportFileToggle.title = "";
|
| 3107 |
+
}
|
| 3108 |
elements.showGraphButton.disabled = !state.sessionId || state.graphLoading;
|
| 3109 |
elements.showGraphButton.classList.toggle("is-pending", state.graphLoading);
|
| 3110 |
elements.showGraphButton.textContent = state.graphLoading
|
|
|
|
| 3202 |
state.sessionConfiguration = null;
|
| 3203 |
state.historyItems = [];
|
| 3204 |
state.memoryItems = [];
|
| 3205 |
+
setSessionMidiSaveResult();
|
| 3206 |
elements.sessionId.textContent = "Open or create a session";
|
| 3207 |
setGraphOpen(false);
|
| 3208 |
clearPhraseBuffers();
|
|
|
|
| 4608 |
state.sessionId = payload.session_id;
|
| 4609 |
state.sessionIsOwned = owned;
|
| 4610 |
state.sessionConfiguration = payload.configuration;
|
| 4611 |
+
setSessionMidiSaveResult();
|
| 4612 |
elements.sessionId.textContent = payload.session_id;
|
| 4613 |
syncSettingsControls(payload.configuration);
|
| 4614 |
await restoreSessionPreferences(payload.configuration);
|
|
|
|
| 4697 |
await refreshSavedSessions();
|
| 4698 |
}
|
| 4699 |
|
| 4700 |
+
async function saveSessionMidi() {
|
| 4701 |
+
if (!state.sessionId) {
|
| 4702 |
+
setSessionMidiSaveResult("Create or open a session before saving MIDI files.", true);
|
| 4703 |
+
setPhraseMessage("Create or open a session before saving MIDI files.", true);
|
| 4704 |
+
return;
|
| 4705 |
+
}
|
| 4706 |
+
|
| 4707 |
+
state.sessionMidiSaving = true;
|
| 4708 |
+
updateSessionActionState();
|
| 4709 |
+
setSessionMidiSaveResult("Saving played and generated phrases as MIDI files...");
|
| 4710 |
+
setPhraseStatus("Saving");
|
| 4711 |
+
setPhraseMessage("Saving played and generated phrases as MIDI files...");
|
| 4712 |
+
try {
|
| 4713 |
+
const payload = await requestSessionMidiZip();
|
| 4714 |
+
let destinationLabel = null;
|
| 4715 |
+
if (midiExportZipSaveSupported()) {
|
| 4716 |
+
let fileHandle = null;
|
| 4717 |
+
try {
|
| 4718 |
+
fileHandle = await chooseMidiExportZipFileHandle(payload.archive_file_name);
|
| 4719 |
+
} catch (error) {
|
| 4720 |
+
if (error?.name === "AbortError") {
|
| 4721 |
+
setPhraseStatus(state.lastCapturedPhrase.length ? "Phrase ready" : "Waiting for MIDI");
|
| 4722 |
+
setSessionMidiSaveResult("MIDI export cancelled.");
|
| 4723 |
+
setPhraseMessage("MIDI export cancelled.");
|
| 4724 |
+
return;
|
| 4725 |
+
}
|
| 4726 |
+
if (error?.name === "NotAllowedError" || /system files/i.test(error?.message || "")) {
|
| 4727 |
+
throw new Error(
|
| 4728 |
+
"Chrome blocked that location. Save the ZIP in Downloads, Documents, Desktop, or Music instead of a system, home, or project folder.",
|
| 4729 |
+
);
|
| 4730 |
+
}
|
| 4731 |
+
throw error;
|
| 4732 |
+
}
|
| 4733 |
+
destinationLabel = await writeZipBlobToFileHandle(payload.blob, fileHandle);
|
| 4734 |
+
} else {
|
| 4735 |
+
destinationLabel = `${downloadZipBlob(payload.blob, payload.archive_file_name)} in your browser downloads`;
|
| 4736 |
+
}
|
| 4737 |
+
|
| 4738 |
+
const message = defaultSessionMidiSaveMessage(payload, destinationLabel);
|
| 4739 |
+
setPhraseStatus("Saved");
|
| 4740 |
+
setSessionMidiSaveResult(message);
|
| 4741 |
+
setPhraseMessage(message);
|
| 4742 |
+
} finally {
|
| 4743 |
+
state.sessionMidiSaving = false;
|
| 4744 |
+
updateSessionActionState();
|
| 4745 |
+
}
|
| 4746 |
+
}
|
| 4747 |
+
|
| 4748 |
async function applyCurrentSessionSettings() {
|
| 4749 |
if (!state.sessionId) {
|
| 4750 |
setPhraseMessage("Create a session before applying settings.", true);
|
|
|
|
| 4973 |
return message;
|
| 4974 |
}
|
| 4975 |
|
| 4976 |
+
function defaultSessionMidiSaveMessage(payload, destinationLabel = null) {
|
| 4977 |
+
const fileCount = Number(payload?.file_count || 0);
|
| 4978 |
+
const inputCount = Number(payload?.input_file_count || 0);
|
| 4979 |
+
const generatedCount = Number(payload?.generated_file_count || 0);
|
| 4980 |
+
const destination = destinationLabel || payload?.directory || "the selected folder";
|
| 4981 |
+
return `Saved ${pluralize(fileCount, "MIDI file")} (${pluralize(inputCount, "played phrase")}, ${pluralize(generatedCount, "generated phrase")}) to ${destination}.`;
|
| 4982 |
+
}
|
| 4983 |
+
|
| 4984 |
function defaultMemoryGenerationMessage(payload, requestedNoteCount) {
|
| 4985 |
return (
|
| 4986 |
payload.status_message ||
|
|
|
|
| 6841 |
}
|
| 6842 |
});
|
| 6843 |
|
| 6844 |
+
elements.saveSessionMidiButton.addEventListener("click", async () => {
|
| 6845 |
+
try {
|
| 6846 |
+
await saveSessionMidi();
|
| 6847 |
+
} catch (error) {
|
| 6848 |
+
setSessionMidiSaveResult(error.message, true);
|
| 6849 |
+
setPhraseMessage(error.message, true);
|
| 6850 |
+
setPhraseStatus("Error");
|
| 6851 |
+
}
|
| 6852 |
+
});
|
| 6853 |
+
|
| 6854 |
+
elements.rememberMidiExportFileToggle.addEventListener("change", async () => {
|
| 6855 |
+
if (elements.rememberMidiExportFileToggle.checked) {
|
| 6856 |
+
setSessionMidiSaveResult("The next selected MIDI ZIP file will be remembered and overwritten on future saves.");
|
| 6857 |
+
return;
|
| 6858 |
+
}
|
| 6859 |
+
state.midiExportFileHandle = null;
|
| 6860 |
+
try {
|
| 6861 |
+
await clearStoredMidiExportFileHandle();
|
| 6862 |
+
setSessionMidiSaveResult("MIDI export ZIP preference cleared.");
|
| 6863 |
+
} catch {
|
| 6864 |
+
setSessionMidiSaveResult("MIDI export ZIP preference cleared for this page.");
|
| 6865 |
+
}
|
| 6866 |
+
});
|
| 6867 |
+
|
| 6868 |
elements.connectMidiButton.addEventListener("click", async () => {
|
| 6869 |
try {
|
| 6870 |
await connectMidi();
|
|
|
|
| 7337 |
}
|
| 7338 |
initializePhraseTimeoutSetting();
|
| 7339 |
initializeInputMonitorPreference();
|
| 7340 |
+
await initializeMidiExportFilePreference();
|
| 7341 |
populatePlaybackChoices();
|
| 7342 |
syncAuthUI();
|
| 7343 |
renderSavedSessions([]);
|
frontend/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>Web Continuator</title>
|
| 7 |
-
<link rel="stylesheet" href="/assets/styles.css?v=
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div class="page-aura"></div>
|
|
@@ -157,6 +157,20 @@
|
|
| 157 |
</ol>
|
| 158 |
</section>
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
<section class="readme-block">
|
| 161 |
<h3>Studio Layout</h3>
|
| 162 |
<ul class="readme-list">
|
|
@@ -887,7 +901,19 @@
|
|
| 887 |
<div class="button-row">
|
| 888 |
<button id="create-session-button">Create Session</button>
|
| 889 |
<button id="reset-session-button" class="ghost">Reset Memory</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 891 |
<div class="settings-compact">
|
| 892 |
<div class="settings-label-row">
|
| 893 |
<span class="small-copy">Current settings</span>
|
|
@@ -1133,6 +1159,6 @@
|
|
| 1133 |
</section>
|
| 1134 |
</main>
|
| 1135 |
|
| 1136 |
-
<script type="module" src="/assets/app.js?v=
|
| 1137 |
</body>
|
| 1138 |
</html>
|
|
|
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>Web Continuator</title>
|
| 7 |
+
<link rel="stylesheet" href="/assets/styles.css?v=20260602-session-midi-zip2" />
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div class="page-aura"></div>
|
|
|
|
| 157 |
</ol>
|
| 158 |
</section>
|
| 159 |
|
| 160 |
+
<section class="readme-block">
|
| 161 |
+
<h3>Saving Session MIDI</h3>
|
| 162 |
+
<p>
|
| 163 |
+
The Session panel can export the current session as a MIDI ZIP
|
| 164 |
+
archive. The archive contains the played input phrases and generated
|
| 165 |
+
continuations in session order.
|
| 166 |
+
</p>
|
| 167 |
+
<p>
|
| 168 |
+
In Chrome and Edge, <strong>Don't ask again</strong> remembers the
|
| 169 |
+
selected ZIP file and overwrites it on future saves. Other browsers
|
| 170 |
+
use the normal download flow.
|
| 171 |
+
</p>
|
| 172 |
+
</section>
|
| 173 |
+
|
| 174 |
<section class="readme-block">
|
| 175 |
<h3>Studio Layout</h3>
|
| 176 |
<ul class="readme-list">
|
|
|
|
| 901 |
<div class="button-row">
|
| 902 |
<button id="create-session-button">Create Session</button>
|
| 903 |
<button id="reset-session-button" class="ghost">Reset Memory</button>
|
| 904 |
+
<button id="save-session-midi-button" class="ghost">
|
| 905 |
+
Save Session MIDI
|
| 906 |
+
</button>
|
| 907 |
+
<label class="toggle compact-toggle">
|
| 908 |
+
<input id="remember-midi-export-file-toggle" type="checkbox" />
|
| 909 |
+
<span>Don't ask again</span>
|
| 910 |
+
</label>
|
| 911 |
</div>
|
| 912 |
+
<p
|
| 913 |
+
id="session-midi-save-result"
|
| 914 |
+
class="session-midi-save-result"
|
| 915 |
+
hidden
|
| 916 |
+
></p>
|
| 917 |
<div class="settings-compact">
|
| 918 |
<div class="settings-label-row">
|
| 919 |
<span class="small-copy">Current settings</span>
|
|
|
|
| 1159 |
</section>
|
| 1160 |
</main>
|
| 1161 |
|
| 1162 |
+
<script type="module" src="/assets/app.js?v=20260602-session-midi-zip2"></script>
|
| 1163 |
</body>
|
| 1164 |
</html>
|
frontend/styles.css
CHANGED
|
@@ -1451,6 +1451,24 @@ input[type="password"]:disabled {
|
|
| 1451 |
line-height: 1.45;
|
| 1452 |
}
|
| 1453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1454 |
.timeline-card {
|
| 1455 |
padding-bottom: 18px;
|
| 1456 |
}
|
|
|
|
| 1451 |
line-height: 1.45;
|
| 1452 |
}
|
| 1453 |
|
| 1454 |
+
.session-midi-save-result {
|
| 1455 |
+
margin: -6px 0 16px;
|
| 1456 |
+
padding: 10px 12px;
|
| 1457 |
+
border-radius: 14px;
|
| 1458 |
+
border: 1px solid rgba(109, 211, 206, 0.22);
|
| 1459 |
+
background: rgba(109, 211, 206, 0.08);
|
| 1460 |
+
color: var(--text);
|
| 1461 |
+
font-size: 0.84rem;
|
| 1462 |
+
line-height: 1.45;
|
| 1463 |
+
overflow-wrap: anywhere;
|
| 1464 |
+
}
|
| 1465 |
+
|
| 1466 |
+
.session-midi-save-result.is-error {
|
| 1467 |
+
border-color: rgba(239, 127, 88, 0.34);
|
| 1468 |
+
background: rgba(239, 127, 88, 0.1);
|
| 1469 |
+
color: #ffd8ca;
|
| 1470 |
+
}
|
| 1471 |
+
|
| 1472 |
.timeline-card {
|
| 1473 |
padding-bottom: 18px;
|
| 1474 |
}
|