pachet commited on
Commit
eacc693
·
1 Parent(s): eba24b8

Add session MIDI ZIP export

Browse files
.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=20260505-label-button-contrast" />
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=20260504-graph-modes"></script>
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
  }