pachet commited on
Commit
edcdc67
·
1 Parent(s): 64075c9

Add MIDI import and Faust playback renderers

Browse files
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ frontend/vendor/faustwasm/libfaust-wasm/*.data filter=lfs diff=lfs merge=lfs -text
2
+ frontend/vendor/faustwasm/libfaust-wasm/*.wasm filter=lfs diff=lfs merge=lfs -text
backend/app/continuator_adapter.py CHANGED
@@ -1,8 +1,10 @@
1
  from __future__ import annotations
2
 
 
3
  from pathlib import Path
4
  import os
5
  import sys
 
6
  import threading
7
 
8
  import mido
@@ -40,10 +42,27 @@ class NoContinuationAvailable(RuntimeError):
40
  """Raised when the engine cannot generate a continuation."""
41
 
42
 
 
 
 
 
 
 
 
 
 
 
43
  def _round_float(value: float) -> float:
44
  return round(float(value), 6)
45
 
46
 
 
 
 
 
 
 
 
47
  def _normalize_notes(notes: list[object]) -> list[object]:
48
  normalized = [note.copy() if hasattr(note, "copy") else note for note in notes]
49
  if not normalized:
@@ -126,15 +145,26 @@ def _notes_to_events(notes: list[object]) -> list[PlaybackMidiEvent]:
126
 
127
 
128
  def _build_phrase_payload(notes: list[object]) -> PhrasePayload:
 
129
  normalized_notes = _normalize_notes(notes)
130
  note_payload = [_note_to_schema(note) for note in normalized_notes]
131
  events = _notes_to_events(normalized_notes)
132
  duration_seconds = max((note.end_seconds for note in note_payload), default=0.0)
 
 
 
 
 
 
 
 
 
133
 
134
  return PhrasePayload(
135
  event_count=len(events),
136
  note_count=len(note_payload),
137
  duration_seconds=_round_float(duration_seconds),
 
138
  events=events,
139
  notes=note_payload,
140
  )
@@ -159,6 +189,7 @@ class ContinuatorSessionEngine:
159
  forget_past: bool = False,
160
  keep_last_inputs: int = 20,
161
  decay_mode: str = "full",
 
162
  seed_midi_file: Path | None = None,
163
  seed_midi_folder: Path | None = None,
164
  ) -> None:
@@ -167,23 +198,33 @@ class ContinuatorSessionEngine:
167
  self._forget_past = forget_past
168
  self._keep_last_inputs = keep_last_inputs
169
  self._decay_mode = decay_mode
 
170
  self._seed_midi_file = seed_midi_file
171
  self._seed_midi_folder = seed_midi_folder
172
  self._seed_sequence_count = 0
173
  self._lock = threading.RLock()
174
  self._continuator = self._create_engine()
175
 
176
- def _create_engine(self) -> Continuator2:
177
- midi_file = str(self._seed_midi_file) if self._seed_midi_file else None
178
- engine = Continuator2(midi_file=midi_file, transposition=self._transposition)
179
- if self._seed_midi_folder:
 
 
 
 
 
 
 
180
  engine.learn_folder(str(self._seed_midi_folder), transpose=self._transposition)
181
  engine.set_learn_input(self._default_learn_input)
182
  engine.set_transpose(self._transposition)
183
  engine.set_forget(self._forget_past)
184
  engine.set_keep_last(self._keep_last_inputs)
185
  engine.set_decay_mode(self._decay_mode)
186
- self._seed_sequence_count = len(getattr(engine.vom, "input_sequences", []))
 
 
187
  return engine
188
 
189
  def apply_settings(
@@ -194,22 +235,48 @@ class ContinuatorSessionEngine:
194
  forget_past: bool | None = None,
195
  keep_last_inputs: int | None = None,
196
  decay_mode: str | None = None,
 
197
  ) -> None:
198
  with self._lock:
 
 
 
 
 
 
199
  if learn_input is not None:
200
  self._default_learn_input = learn_input
201
- self._continuator.set_learn_input(learn_input)
202
  if transposition is not None:
203
  self._transposition = transposition
204
- self._continuator.set_transpose(transposition)
205
  if forget_past is not None:
206
  self._forget_past = forget_past
207
- self._continuator.set_forget(forget_past)
208
  if keep_last_inputs is not None:
209
  self._keep_last_inputs = keep_last_inputs
210
- self._continuator.set_keep_last(keep_last_inputs)
211
  if decay_mode is not None:
212
  self._decay_mode = decay_mode
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  self._continuator.set_decay_mode(decay_mode)
214
 
215
  def reset(self) -> None:
@@ -223,22 +290,132 @@ class ContinuatorSessionEngine:
223
  seed_count = min(self._seed_sequence_count, len(payloads))
224
  return payloads, seed_count
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  def learn_phrase_events(self, phrase_events: list[MidiEvent]) -> PhrasePayload:
227
  with self._lock:
228
- messages = [_event_to_mido_message(event) for event in phrase_events]
229
- input_phrase = self._continuator.get_phrase_from_mido(messages)
230
- if not input_phrase:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  raise NoContinuationAvailable(
232
- "The stored phrase did not contain any complete notes to rebuild."
233
  )
234
- self._continuator.learn_phrase(input_phrase, self._continuator.transpose)
235
- return _build_phrase_payload(input_phrase)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
  def continue_phrase(
238
  self,
239
  phrase_events: list[MidiEvent],
240
  learn_input: bool | None = None,
241
  continuation_note_count: int | None = None,
 
242
  ) -> tuple[PhrasePayload, PhrasePayload, str | None]:
243
  with self._lock:
244
  messages = [_event_to_mido_message(event) for event in phrase_events]
@@ -254,22 +431,29 @@ class ContinuatorSessionEngine:
254
  self._continuator.learn_phrase(input_phrase, self._continuator.transpose)
255
 
256
  status_message = None
257
- constraints = {target_note_count: self._continuator.get_end_vp()}
258
- generated_sequence = self._continuator.sample_sequence(
259
- prefix=input_phrase,
260
- length=target_note_count + 1,
261
- constraints=constraints,
262
- )
263
- if generated_sequence is None:
 
 
 
 
 
 
 
 
 
 
 
264
  generated_sequence = self._continuator.sample_sequence(
265
  prefix=input_phrase,
266
  length=target_note_count,
267
  constraints={},
268
  )
269
- status_message = (
270
- "Used a same-length continuation without the hard end constraint "
271
- "because the exact-ending version had no solution."
272
- )
273
 
274
  if generated_sequence is None:
275
  raise NoContinuationAvailable("The Continuator could not find a valid continuation.")
 
1
  from __future__ import annotations
2
 
3
+ from dataclasses import dataclass
4
  from pathlib import Path
5
  import os
6
  import sys
7
+ from tempfile import TemporaryDirectory
8
  import threading
9
 
10
  import mido
 
42
  """Raised when the engine cannot generate a continuation."""
43
 
44
 
45
+ class MidiImportError(RuntimeError):
46
+ """Raised when uploaded MIDI files cannot be imported."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ImportedMidiPhrase:
51
+ file_name: str
52
+ payload: PhrasePayload
53
+
54
+
55
  def _round_float(value: float) -> float:
56
  return round(float(value), 6)
57
 
58
 
59
+ def _normalize_uploaded_file_name(raw_name: str | None, fallback: str) -> str:
60
+ candidate = (raw_name or fallback).replace("\\", "/")
61
+ parts = [part for part in candidate.split("/") if part and part not in {".", ".."}]
62
+ normalized = "/".join(parts)
63
+ return normalized or fallback
64
+
65
+
66
  def _normalize_notes(notes: list[object]) -> list[object]:
67
  normalized = [note.copy() if hasattr(note, "copy") else note for note in notes]
68
  if not normalized:
 
145
 
146
 
147
  def _build_phrase_payload(notes: list[object]) -> PhrasePayload:
148
+ raw_notes = list(notes)
149
  normalized_notes = _normalize_notes(notes)
150
  note_payload = [_note_to_schema(note) for note in normalized_notes]
151
  events = _notes_to_events(normalized_notes)
152
  duration_seconds = max((note.end_seconds for note in note_payload), default=0.0)
153
+ handoff_seconds = None
154
+ if raw_notes:
155
+ last_note = raw_notes[-1]
156
+ next_onset_beats = max(
157
+ 0.0,
158
+ float(last_note.start_time)
159
+ + max(0.0, float(last_note.duration) + float(getattr(last_note, "next_start_delta", 0.0))),
160
+ )
161
+ handoff_seconds = _round_float(next_onset_beats / 2.0)
162
 
163
  return PhrasePayload(
164
  event_count=len(events),
165
  note_count=len(note_payload),
166
  duration_seconds=_round_float(duration_seconds),
167
+ handoff_seconds=handoff_seconds,
168
  events=events,
169
  notes=note_payload,
170
  )
 
189
  forget_past: bool = False,
190
  keep_last_inputs: int = 20,
191
  decay_mode: str = "full",
192
+ markov_order: int = 4,
193
  seed_midi_file: Path | None = None,
194
  seed_midi_folder: Path | None = None,
195
  ) -> None:
 
198
  self._forget_past = forget_past
199
  self._keep_last_inputs = keep_last_inputs
200
  self._decay_mode = decay_mode
201
+ self._markov_order = markov_order
202
  self._seed_midi_file = seed_midi_file
203
  self._seed_midi_folder = seed_midi_folder
204
  self._seed_sequence_count = 0
205
  self._lock = threading.RLock()
206
  self._continuator = self._create_engine()
207
 
208
+ def _create_engine(self, *, load_seed_material: bool = True) -> Continuator2:
209
+ midi_file = None
210
+ if load_seed_material and self._seed_midi_file:
211
+ midi_file = str(self._seed_midi_file)
212
+
213
+ engine = Continuator2(
214
+ midi_file=midi_file,
215
+ kmax=self._markov_order,
216
+ transposition=self._transposition,
217
+ )
218
+ if load_seed_material and self._seed_midi_folder:
219
  engine.learn_folder(str(self._seed_midi_folder), transpose=self._transposition)
220
  engine.set_learn_input(self._default_learn_input)
221
  engine.set_transpose(self._transposition)
222
  engine.set_forget(self._forget_past)
223
  engine.set_keep_last(self._keep_last_inputs)
224
  engine.set_decay_mode(self._decay_mode)
225
+ self._seed_sequence_count = (
226
+ len(getattr(engine.vom, "input_sequences", [])) if load_seed_material else 0
227
+ )
228
  return engine
229
 
230
  def apply_settings(
 
235
  forget_past: bool | None = None,
236
  keep_last_inputs: int | None = None,
237
  decay_mode: str | None = None,
238
+ markov_order: int | None = None,
239
  ) -> None:
240
  with self._lock:
241
+ rebuild_required = markov_order is not None and markov_order != self._markov_order
242
+ preserved_payloads: list[PhrasePayload] = []
243
+ preserved_seed_count = self._seed_sequence_count
244
+ if rebuild_required:
245
+ preserved_payloads, preserved_seed_count = self.get_memory_snapshot()
246
+
247
  if learn_input is not None:
248
  self._default_learn_input = learn_input
 
249
  if transposition is not None:
250
  self._transposition = transposition
 
251
  if forget_past is not None:
252
  self._forget_past = forget_past
 
253
  if keep_last_inputs is not None:
254
  self._keep_last_inputs = keep_last_inputs
 
255
  if decay_mode is not None:
256
  self._decay_mode = decay_mode
257
+ if markov_order is not None:
258
+ self._markov_order = markov_order
259
+
260
+ if rebuild_required:
261
+ self._continuator = self._create_engine(load_seed_material=False)
262
+ for payload in preserved_payloads:
263
+ phrase_events = [MidiEvent.model_validate(event) for event in payload.events]
264
+ try:
265
+ self._learn_phrase_events_locked(phrase_events, transpose=False)
266
+ except NoContinuationAvailable:
267
+ continue
268
+ self._seed_sequence_count = min(preserved_seed_count, len(preserved_payloads))
269
+ return
270
+
271
+ if learn_input is not None:
272
+ self._continuator.set_learn_input(learn_input)
273
+ if transposition is not None:
274
+ self._continuator.set_transpose(transposition)
275
+ if forget_past is not None:
276
+ self._continuator.set_forget(forget_past)
277
+ if keep_last_inputs is not None:
278
+ self._continuator.set_keep_last(keep_last_inputs)
279
+ if decay_mode is not None:
280
  self._continuator.set_decay_mode(decay_mode)
281
 
282
  def reset(self) -> None:
 
290
  seed_count = min(self._seed_sequence_count, len(payloads))
291
  return payloads, seed_count
292
 
293
+ def _learn_phrase_events_locked(
294
+ self,
295
+ phrase_events: list[MidiEvent],
296
+ *,
297
+ transpose: bool,
298
+ ) -> PhrasePayload:
299
+ messages = [_event_to_mido_message(event) for event in phrase_events]
300
+ input_phrase = self._continuator.get_phrase_from_mido(messages)
301
+ if not input_phrase:
302
+ raise NoContinuationAvailable(
303
+ "The stored phrase did not contain any complete notes to rebuild."
304
+ )
305
+ self._continuator.learn_phrase(input_phrase, transpose)
306
+ return _build_phrase_payload(input_phrase)
307
+
308
  def learn_phrase_events(self, phrase_events: list[MidiEvent]) -> PhrasePayload:
309
  with self._lock:
310
+ return self._learn_phrase_events_locked(
311
+ phrase_events,
312
+ transpose=self._continuator.transpose,
313
+ )
314
+
315
+ def import_midi_files(
316
+ self,
317
+ midi_files: list[tuple[str, bytes]],
318
+ ) -> tuple[list[ImportedMidiPhrase], list[str]]:
319
+ with self._lock:
320
+ imported: list[ImportedMidiPhrase] = []
321
+ skipped: list[str] = []
322
+ with TemporaryDirectory(prefix="continuator-midi-import-") as temp_dir:
323
+ temp_root = Path(temp_dir)
324
+ for index, (raw_name, raw_bytes) in enumerate(midi_files):
325
+ file_name = _normalize_uploaded_file_name(
326
+ raw_name,
327
+ f"imported_{index + 1}.mid",
328
+ )
329
+ suffix = Path(file_name).suffix.lower()
330
+ if suffix not in {".mid", ".midi"} or not raw_bytes:
331
+ skipped.append(file_name)
332
+ continue
333
+
334
+ temp_path = temp_root / f"upload_{index:04d}{suffix}"
335
+ temp_path.write_bytes(raw_bytes)
336
+ try:
337
+ notes = list(self._continuator.extract_notes(str(temp_path)))
338
+ except Exception:
339
+ skipped.append(file_name)
340
+ continue
341
+
342
+ if not notes:
343
+ skipped.append(file_name)
344
+ continue
345
+
346
+ self._continuator.learn_phrase(notes, self._continuator.transpose)
347
+ imported.append(
348
+ ImportedMidiPhrase(
349
+ file_name=file_name,
350
+ payload=_build_phrase_payload(notes),
351
+ )
352
+ )
353
+
354
+ if not imported:
355
+ raise MidiImportError("No importable MIDI files were found in the selection.")
356
+
357
+ return imported, skipped
358
+
359
+ def generate_phrase(
360
+ self,
361
+ note_count: int | None = None,
362
+ enforce_end_constraint: bool = True,
363
+ ) -> tuple[PhrasePayload, str | None]:
364
+ with self._lock:
365
+ if not getattr(self._continuator.vom, "input_sequences", []):
366
  raise NoContinuationAvailable(
367
+ "The Continuator memory is empty. Load MIDI or learn a phrase first."
368
  )
369
+
370
+ target_note_count = note_count or 12
371
+ status_message = None
372
+ if enforce_end_constraint:
373
+ constraints = {target_note_count: self._continuator.get_end_vp()}
374
+ generated_sequence = self._continuator.sample_sequence(
375
+ prefix=None,
376
+ length=target_note_count + 1,
377
+ constraints=constraints,
378
+ )
379
+ if generated_sequence is None:
380
+ generated_sequence = self._continuator.sample_sequence(
381
+ prefix=None,
382
+ length=target_note_count,
383
+ constraints={},
384
+ )
385
+ status_message = (
386
+ "Generated from memory without the hard end constraint "
387
+ "because the exact-ending version had no solution."
388
+ )
389
+ else:
390
+ generated_sequence = self._continuator.sample_sequence(
391
+ prefix=None,
392
+ length=target_note_count,
393
+ constraints={},
394
+ )
395
+
396
+ if generated_sequence is None:
397
+ raise NoContinuationAvailable(
398
+ "The Continuator could not generate a fresh phrase from the current memory."
399
+ )
400
+
401
+ rendered_vp_sequence = generated_sequence
402
+ if rendered_vp_sequence and rendered_vp_sequence[-1] == self._continuator.get_end_vp():
403
+ rendered_vp_sequence = rendered_vp_sequence[:-1]
404
+
405
+ if not rendered_vp_sequence:
406
+ raise NoContinuationAvailable(
407
+ "The Continuator returned an empty phrase from the current memory."
408
+ )
409
+
410
+ rendered_sequence = self._continuator.realize_vp_sequence(rendered_vp_sequence)
411
+ return _build_phrase_payload(rendered_sequence), status_message
412
 
413
  def continue_phrase(
414
  self,
415
  phrase_events: list[MidiEvent],
416
  learn_input: bool | None = None,
417
  continuation_note_count: int | None = None,
418
+ enforce_end_constraint: bool = True,
419
  ) -> tuple[PhrasePayload, PhrasePayload, str | None]:
420
  with self._lock:
421
  messages = [_event_to_mido_message(event) for event in phrase_events]
 
431
  self._continuator.learn_phrase(input_phrase, self._continuator.transpose)
432
 
433
  status_message = None
434
+ if enforce_end_constraint:
435
+ constraints = {target_note_count: self._continuator.get_end_vp()}
436
+ generated_sequence = self._continuator.sample_sequence(
437
+ prefix=input_phrase,
438
+ length=target_note_count + 1,
439
+ constraints=constraints,
440
+ )
441
+ if generated_sequence is None:
442
+ generated_sequence = self._continuator.sample_sequence(
443
+ prefix=input_phrase,
444
+ length=target_note_count,
445
+ constraints={},
446
+ )
447
+ status_message = (
448
+ "Used a same-length continuation without the hard end constraint "
449
+ "because the exact-ending version had no solution."
450
+ )
451
+ else:
452
  generated_sequence = self._continuator.sample_sequence(
453
  prefix=input_phrase,
454
  length=target_note_count,
455
  constraints={},
456
  )
 
 
 
 
457
 
458
  if generated_sequence is None:
459
  raise NoContinuationAvailable("The Continuator could not find a valid continuation.")
backend/app/main.py CHANGED
@@ -1,6 +1,6 @@
1
  from __future__ import annotations
2
 
3
- from fastapi import Cookie, Depends, FastAPI, HTTPException, Query, Request, Response
4
  from fastapi.responses import FileResponse
5
  from fastapi.staticfiles import StaticFiles
6
 
@@ -20,6 +20,9 @@ from .schemas import (
20
  ContinueResponse,
21
  CreateSessionRequest,
22
  CreateSessionResponse,
 
 
 
23
  LoginRequest,
24
  LogoutResponse,
25
  OpenSessionResponse,
@@ -32,7 +35,7 @@ from .schemas import (
32
  UpdateSessionSettingsResponse,
33
  UserSessionsResponse,
34
  )
35
- from .session_manager import NoContinuationAvailable, SessionManager, UnknownSessionError
36
  from .storage import PhraseStorage
37
 
38
 
@@ -236,6 +239,29 @@ def continue_phrase(
236
  raise HTTPException(status_code=409, detail=str(error)) from error
237
 
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  @app.get(
240
  "/api/sessions/{session_id}/history",
241
  response_model=SessionHistoryResponse,
@@ -274,6 +300,35 @@ def session_memory(
274
  raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
275
 
276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  @app.post(
278
  "/api/sessions/{session_id}/reset",
279
  response_model=ResetSessionResponse,
 
1
  from __future__ import annotations
2
 
3
+ from fastapi import Cookie, Depends, FastAPI, File, HTTPException, Query, Request, Response, UploadFile
4
  from fastapi.responses import FileResponse
5
  from fastapi.staticfiles import StaticFiles
6
 
 
20
  ContinueResponse,
21
  CreateSessionRequest,
22
  CreateSessionResponse,
23
+ GeneratePhraseRequest,
24
+ GeneratePhraseResponse,
25
+ ImportMidiResponse,
26
  LoginRequest,
27
  LogoutResponse,
28
  OpenSessionResponse,
 
35
  UpdateSessionSettingsResponse,
36
  UserSessionsResponse,
37
  )
38
+ from .session_manager import MidiImportError, NoContinuationAvailable, SessionManager, UnknownSessionError
39
  from .storage import PhraseStorage
40
 
41
 
 
239
  raise HTTPException(status_code=409, detail=str(error)) from error
240
 
241
 
242
+ @app.post(
243
+ "/api/sessions/{session_id}/generate",
244
+ response_model=GeneratePhraseResponse,
245
+ tags=["continuator"],
246
+ )
247
+ def generate_phrase(
248
+ session_id: str,
249
+ payload: GeneratePhraseRequest,
250
+ current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
251
+ ) -> GeneratePhraseResponse:
252
+ try:
253
+ return session_manager.generate_phrase(
254
+ session_id,
255
+ None if current_user is None else current_user.id,
256
+ note_count=payload.note_count,
257
+ enforce_end_constraint=payload.enforce_end_constraint,
258
+ )
259
+ except UnknownSessionError as error:
260
+ raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
261
+ except NoContinuationAvailable as error:
262
+ raise HTTPException(status_code=409, detail=str(error)) from error
263
+
264
+
265
  @app.get(
266
  "/api/sessions/{session_id}/history",
267
  response_model=SessionHistoryResponse,
 
300
  raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
301
 
302
 
303
+ @app.post(
304
+ "/api/sessions/{session_id}/import-midi",
305
+ response_model=ImportMidiResponse,
306
+ tags=["session"],
307
+ )
308
+ async def import_session_midi(
309
+ session_id: str,
310
+ files: list[UploadFile] = File(...),
311
+ current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
312
+ ) -> ImportMidiResponse:
313
+ uploaded_files: list[tuple[str, bytes]] = []
314
+ try:
315
+ for index, upload in enumerate(files):
316
+ file_name = upload.filename or f"imported_{index + 1}.mid"
317
+ uploaded_files.append((file_name, await upload.read()))
318
+ return session_manager.import_midi_files(
319
+ session_id,
320
+ None if current_user is None else current_user.id,
321
+ uploaded_files,
322
+ )
323
+ except UnknownSessionError as error:
324
+ raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
325
+ except MidiImportError as error:
326
+ raise HTTPException(status_code=400, detail=str(error)) from error
327
+ finally:
328
+ for upload in files:
329
+ await upload.close()
330
+
331
+
332
  @app.post(
333
  "/api/sessions/{session_id}/reset",
334
  response_model=ResetSessionResponse,
backend/app/schemas.py CHANGED
@@ -36,6 +36,7 @@ class PhrasePayload(BaseModel):
36
  event_count: int = Field(ge=0)
37
  note_count: int = Field(ge=0)
38
  duration_seconds: float = Field(ge=0.0)
 
39
  events: list[PlaybackMidiEvent]
40
  notes: list[PhraseNote]
41
 
@@ -46,6 +47,7 @@ class CreateSessionRequest(BaseModel):
46
  forget_past: bool = False
47
  keep_last_inputs: int = Field(default=20, ge=1, le=500)
48
  decay_mode: DecayMode = "full"
 
49
 
50
 
51
  class SessionConfiguration(BaseModel):
@@ -54,6 +56,7 @@ class SessionConfiguration(BaseModel):
54
  forget_past: bool
55
  keep_last_inputs: int = Field(ge=1, le=500)
56
  decay_mode: DecayMode
 
57
  seeded: bool
58
 
59
 
@@ -68,6 +71,7 @@ class ContinueRequest(BaseModel):
68
  phrase: list[MidiEvent] = Field(min_length=1)
69
  learn_input: bool | None = None
70
  continuation_note_count: int | None = Field(default=None, ge=1)
 
71
 
72
 
73
  class ContinueResponse(BaseModel):
@@ -79,6 +83,35 @@ class ContinueResponse(BaseModel):
79
  status_message: str | None = None
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  class HistoryItem(BaseModel):
83
  id: str
84
  request_id: str
@@ -129,6 +162,7 @@ class UpdateSessionSettingsRequest(BaseModel):
129
  forget_past: bool | None = None
130
  keep_last_inputs: int | None = Field(default=None, ge=1, le=500)
131
  decay_mode: DecayMode | None = None
 
132
 
133
 
134
  class UpdateSessionSettingsResponse(BaseModel):
 
36
  event_count: int = Field(ge=0)
37
  note_count: int = Field(ge=0)
38
  duration_seconds: float = Field(ge=0.0)
39
+ handoff_seconds: float | None = Field(default=None, ge=0.0)
40
  events: list[PlaybackMidiEvent]
41
  notes: list[PhraseNote]
42
 
 
47
  forget_past: bool = False
48
  keep_last_inputs: int = Field(default=20, ge=1, le=500)
49
  decay_mode: DecayMode = "full"
50
+ markov_order: int = Field(default=4, ge=1, le=16)
51
 
52
 
53
  class SessionConfiguration(BaseModel):
 
56
  forget_past: bool
57
  keep_last_inputs: int = Field(ge=1, le=500)
58
  decay_mode: DecayMode
59
+ markov_order: int = Field(default=4, ge=1, le=16)
60
  seeded: bool
61
 
62
 
 
71
  phrase: list[MidiEvent] = Field(min_length=1)
72
  learn_input: bool | None = None
73
  continuation_note_count: int | None = Field(default=None, ge=1)
74
+ enforce_end_constraint: bool = True
75
 
76
 
77
  class ContinueResponse(BaseModel):
 
83
  status_message: str | None = None
84
 
85
 
86
+ class GeneratePhraseRequest(BaseModel):
87
+ note_count: int | None = Field(default=None, ge=1, le=512)
88
+ enforce_end_constraint: bool = True
89
+
90
+
91
+ class GeneratePhraseResponse(BaseModel):
92
+ session_id: str
93
+ request_id: str
94
+ created_at: str
95
+ generated_phrase: PhrasePayload
96
+ status_message: str | None = None
97
+
98
+
99
+ class ImportedMidiFileSummary(BaseModel):
100
+ file_name: str = Field(min_length=1)
101
+ event_count: int = Field(ge=0)
102
+ note_count: int = Field(ge=0)
103
+ duration_seconds: float = Field(ge=0.0)
104
+
105
+
106
+ class ImportMidiResponse(BaseModel):
107
+ session_id: str
108
+ created_at: str
109
+ imported_file_count: int = Field(ge=0)
110
+ skipped_file_count: int = Field(ge=0)
111
+ imported_files: list[ImportedMidiFileSummary]
112
+ skipped_files: list[str] = Field(default_factory=list)
113
+
114
+
115
  class HistoryItem(BaseModel):
116
  id: str
117
  request_id: str
 
162
  forget_past: bool | None = None
163
  keep_last_inputs: int | None = Field(default=None, ge=1, le=500)
164
  decay_mode: DecayMode | None = None
165
+ markov_order: int | None = Field(default=None, ge=1, le=16)
166
 
167
 
168
  class UpdateSessionSettingsResponse(BaseModel):
backend/app/session_manager.py CHANGED
@@ -6,13 +6,16 @@ from pathlib import Path
6
  import threading
7
  import uuid
8
 
9
- from .continuator_adapter import ContinuatorSessionEngine, NoContinuationAvailable
10
  from .schemas import (
11
  ContinueRequest,
12
  ContinueResponse,
13
  CreateSessionRequest,
14
  CreateSessionResponse,
 
15
  HistoryItem,
 
 
16
  MemoryPhraseItem,
17
  MidiEvent,
18
  OpenSessionResponse,
@@ -72,6 +75,7 @@ class SessionManager:
72
  forget_past=request.forget_past,
73
  keep_last_inputs=request.keep_last_inputs,
74
  decay_mode=request.decay_mode,
 
75
  seeded=self.seeded,
76
  )
77
 
@@ -82,6 +86,7 @@ class SessionManager:
82
  forget_past=configuration.forget_past,
83
  keep_last_inputs=configuration.keep_last_inputs,
84
  decay_mode=configuration.decay_mode,
 
85
  seed_midi_file=self.seed_midi_file,
86
  seed_midi_folder=self.seed_midi_folder,
87
  )
@@ -262,6 +267,7 @@ class SessionManager:
262
  request.phrase,
263
  learn_input=should_learn,
264
  continuation_note_count=request.continuation_note_count,
 
265
  )
266
 
267
  state.last_seen_at = created_at
@@ -294,6 +300,85 @@ class SessionManager:
294
  status_message=status_message,
295
  )
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  def get_history(
298
  self,
299
  session_id: str,
@@ -378,6 +463,7 @@ class SessionManager:
378
 
379
 
380
  __all__ = [
 
381
  "NoContinuationAvailable",
382
  "SessionManager",
383
  "UnknownSessionError",
 
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
  HistoryItem,
17
+ ImportedMidiFileSummary,
18
+ ImportMidiResponse,
19
  MemoryPhraseItem,
20
  MidiEvent,
21
  OpenSessionResponse,
 
75
  forget_past=request.forget_past,
76
  keep_last_inputs=request.keep_last_inputs,
77
  decay_mode=request.decay_mode,
78
+ markov_order=request.markov_order,
79
  seeded=self.seeded,
80
  )
81
 
 
86
  forget_past=configuration.forget_past,
87
  keep_last_inputs=configuration.keep_last_inputs,
88
  decay_mode=configuration.decay_mode,
89
+ markov_order=configuration.markov_order,
90
  seed_midi_file=self.seed_midi_file,
91
  seed_midi_folder=self.seed_midi_folder,
92
  )
 
267
  request.phrase,
268
  learn_input=should_learn,
269
  continuation_note_count=request.continuation_note_count,
270
+ enforce_end_constraint=request.enforce_end_constraint,
271
  )
272
 
273
  state.last_seen_at = created_at
 
300
  status_message=status_message,
301
  )
302
 
303
+ def generate_phrase(
304
+ self,
305
+ session_id: str,
306
+ owner_user_id: str | None,
307
+ note_count: int | None = None,
308
+ enforce_end_constraint: bool = True,
309
+ ) -> GeneratePhraseResponse:
310
+ state = self._require_session(session_id, owner_user_id)
311
+ created_at = utc_now_iso()
312
+ request_id = uuid.uuid4().hex
313
+ generated_phrase, status_message = state.engine.generate_phrase(
314
+ note_count=note_count,
315
+ enforce_end_constraint=enforce_end_constraint,
316
+ )
317
+
318
+ state.last_seen_at = created_at
319
+ self.storage.touch_session(state.session_id, created_at)
320
+ self.storage.log_phrase(
321
+ phrase_id=uuid.uuid4().hex,
322
+ request_id=request_id,
323
+ session_id=state.session_id,
324
+ kind="generated",
325
+ created_at=created_at,
326
+ learned=False,
327
+ payload=generated_phrase.model_dump(mode="json"),
328
+ )
329
+
330
+ return GeneratePhraseResponse(
331
+ session_id=state.session_id,
332
+ request_id=request_id,
333
+ created_at=created_at,
334
+ generated_phrase=generated_phrase,
335
+ status_message=status_message,
336
+ )
337
+
338
+ def import_midi_files(
339
+ self,
340
+ session_id: str,
341
+ owner_user_id: str | None,
342
+ midi_files: list[tuple[str, bytes]],
343
+ ) -> ImportMidiResponse:
344
+ state = self._require_session(session_id, owner_user_id)
345
+ if not midi_files:
346
+ raise MidiImportError("Select at least one MIDI file to import.")
347
+
348
+ created_at = utc_now_iso()
349
+ request_id = uuid.uuid4().hex
350
+ imported_files, skipped_files = state.engine.import_midi_files(midi_files)
351
+
352
+ state.last_seen_at = created_at
353
+ self.storage.touch_session(state.session_id, created_at)
354
+ for index, imported_file in enumerate(imported_files):
355
+ self.storage.log_phrase(
356
+ phrase_id=f"{request_id}-import-{index:04d}",
357
+ request_id=request_id,
358
+ session_id=state.session_id,
359
+ kind="input",
360
+ created_at=created_at,
361
+ learned=True,
362
+ payload=imported_file.payload.model_dump(mode="json"),
363
+ )
364
+
365
+ return ImportMidiResponse(
366
+ session_id=state.session_id,
367
+ created_at=created_at,
368
+ imported_file_count=len(imported_files),
369
+ skipped_file_count=len(skipped_files),
370
+ imported_files=[
371
+ ImportedMidiFileSummary(
372
+ file_name=imported_file.file_name,
373
+ event_count=imported_file.payload.event_count,
374
+ note_count=imported_file.payload.note_count,
375
+ duration_seconds=imported_file.payload.duration_seconds,
376
+ )
377
+ for imported_file in imported_files
378
+ ],
379
+ skipped_files=skipped_files,
380
+ )
381
+
382
  def get_history(
383
  self,
384
  session_id: str,
 
463
 
464
 
465
  __all__ = [
466
+ "MidiImportError",
467
  "NoContinuationAvailable",
468
  "SessionManager",
469
  "UnknownSessionError",
backend/requirements.txt CHANGED
@@ -3,4 +3,4 @@ uvicorn[standard]>=0.30,<1
3
  pydantic>=2.7,<3
4
  numpy>=2.0,<3
5
  mido>=1.3,<2
6
-
 
3
  pydantic>=2.7,<3
4
  numpy>=2.0,<3
5
  mido>=1.3,<2
6
+ python-multipart>=0.0.9,<1
backend/vendor/ctor/continuator.py CHANGED
@@ -230,27 +230,30 @@ class Continuator2:
230
  pending_notes = np.empty(128, dtype=object)
231
  pending_start_times = np.zeros(128)
232
  current_time = 0
233
- for track in mid.tracks:
234
- for msg in track:
235
- current_time += 2 * mido.tick2second(msg.time, ticks_per_beat=resolution, tempo=500000) # in beats
236
- if msg.type == 'set_tempo':
237
- self.tempo_msgs.append(msg.tempo)
238
- if msg.type == "note_on" and msg.velocity > 0:
239
- new_note = Note(msg.note, msg.velocity, 0)
240
- notes.append(new_note) # Store MIDI note number
241
- pending_notes[msg.note] = new_note
242
- pending_start_times[msg.note] = current_time
243
- new_note.set_start_time(current_time)
244
- new_note.set_duration(1) # beat
245
- if msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0):
246
- if pending_notes[msg.note] is None:
247
- print("found 0 velocity note, skipping it")
248
- continue
249
- pending_note = pending_notes[msg.note]
250
- duration = current_time - pending_start_times[msg.note]
251
- pending_note.set_duration(duration)
252
- pending_notes[msg.note] = None
253
- pending_start_times[msg.note] = 0
 
 
 
254
  # sets the note status w/r their neighbors
255
  self.set_delta_notes(notes)
256
  return np.array(notes)
 
230
  pending_notes = np.empty(128, dtype=object)
231
  pending_start_times = np.zeros(128)
232
  current_time = 0
233
+ for msg in mido.merge_tracks(mid.tracks):
234
+ current_time += 2 * mido.tick2second(
235
+ msg.time,
236
+ ticks_per_beat=resolution,
237
+ tempo=500000,
238
+ ) # in beats
239
+ if msg.type == 'set_tempo':
240
+ self.tempo_msgs.append(msg.tempo)
241
+ if msg.type == "note_on" and msg.velocity > 0:
242
+ new_note = Note(msg.note, msg.velocity, 0)
243
+ notes.append(new_note) # Store MIDI note number
244
+ pending_notes[msg.note] = new_note
245
+ pending_start_times[msg.note] = current_time
246
+ new_note.set_start_time(current_time)
247
+ new_note.set_duration(1) # beat
248
+ if msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0):
249
+ if pending_notes[msg.note] is None:
250
+ print("found 0 velocity note, skipping it")
251
+ continue
252
+ pending_note = pending_notes[msg.note]
253
+ duration = current_time - pending_start_times[msg.note]
254
+ pending_note.set_duration(duration)
255
+ pending_notes[msg.note] = None
256
+ pending_start_times[msg.note] = 0
257
  # sets the note status w/r their neighbors
258
  self.set_delta_notes(notes)
259
  return np.array(notes)
backend/vendor/midi_stuff/mini_muse.py CHANGED
@@ -164,25 +164,28 @@ class Realized_Chord:
164
  pending_notes = np.empty(128, dtype=object)
165
  pending_start_times = np.zeros(128)
166
  current_time = 0
167
- for track in mid.tracks:
168
- for msg in track:
169
- current_time += 2 * mido.tick2second(msg.time, ticks_per_beat=resolution, tempo=500000) # in beats
170
- if msg.type == "note_on" and msg.velocity > 0:
171
- new_note = Note(msg.note, msg.velocity, 0)
172
- notes.append(new_note) # Store MIDI note number
173
- pending_notes[msg.note] = new_note
174
- pending_start_times[msg.note] = current_time
175
- new_note.set_start_time(current_time)
176
- new_note.set_duration(1) # beat
177
- if msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0):
178
- if pending_notes[msg.note] is None:
179
- print("found 0 velocity note, skipping it")
180
- continue
181
- pending_note = pending_notes[msg.note]
182
- duration = current_time - pending_start_times[msg.note]
183
- pending_note.set_duration(duration)
184
- pending_notes[msg.note] = None
185
- pending_start_times[msg.note] = 0
 
 
 
186
  return np.array(notes)
187
 
188
  @classmethod
 
164
  pending_notes = np.empty(128, dtype=object)
165
  pending_start_times = np.zeros(128)
166
  current_time = 0
167
+ for msg in mido.merge_tracks(mid.tracks):
168
+ current_time += 2 * mido.tick2second(
169
+ msg.time,
170
+ ticks_per_beat=resolution,
171
+ tempo=500000,
172
+ ) # in beats
173
+ if msg.type == "note_on" and msg.velocity > 0:
174
+ new_note = Note(msg.note, msg.velocity, 0)
175
+ notes.append(new_note) # Store MIDI note number
176
+ pending_notes[msg.note] = new_note
177
+ pending_start_times[msg.note] = current_time
178
+ new_note.set_start_time(current_time)
179
+ new_note.set_duration(1) # beat
180
+ if msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0):
181
+ if pending_notes[msg.note] is None:
182
+ print("found 0 velocity note, skipping it")
183
+ continue
184
+ pending_note = pending_notes[msg.note]
185
+ duration = current_time - pending_start_times[msg.note]
186
+ pending_note.set_duration(duration)
187
+ pending_notes[msg.note] = None
188
+ pending_start_times[msg.note] = 0
189
  return np.array(notes)
190
 
191
  @classmethod
frontend/app.js CHANGED
@@ -1,8 +1,35 @@
1
- const BROWSER_SYNTH_ID = "__browser_synth__";
 
 
 
 
2
  const PHRASE_TIMEOUT_MS = 1000;
3
  const PLAYBACK_START_DELAY_MS = 80;
4
  const INFINITE_MIN_LOOKAHEAD_MS = 600;
5
  const INFINITE_MAX_LOOKAHEAD_MS = 2200;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  const elements = {
8
  serverStatus: document.querySelector("#server-status"),
@@ -60,10 +87,34 @@ const elements = {
60
  viewTabs: document.querySelectorAll("[data-view-tab]"),
61
  midiInputSelect: document.querySelector("#midi-input-select"),
62
  midiOutputSelect: document.querySelector("#midi-output-select"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  learnInputToggle: document.querySelector("#learn-input-toggle"),
64
  autoSendToggle: document.querySelector("#auto-send-toggle"),
65
  transposeToggle: document.querySelector("#transpose-toggle"),
66
  forgetToggle: document.querySelector("#forget-toggle"),
 
67
  keepLastInput: document.querySelector("#keep-last-input"),
68
  decayModeSelect: document.querySelector("#decay-mode-select"),
69
  continuationLengthInput: document.querySelector("#continuation-length-input"),
@@ -72,9 +123,14 @@ const elements = {
72
  createSessionButton: document.querySelector("#create-session-button"),
73
  resetSessionButton: document.querySelector("#reset-session-button"),
74
  applySettingsButton: document.querySelector("#apply-settings-button"),
 
 
 
 
75
  connectMidiButton: document.querySelector("#connect-midi-button"),
76
  refreshMidiButton: document.querySelector("#refresh-midi-button"),
77
  sendPhraseButton: document.querySelector("#send-phrase-button"),
 
78
  replayGeneratedButton: document.querySelector("#replay-generated-button"),
79
  clearPhraseButton: document.querySelector("#clear-phrase-button"),
80
  };
@@ -90,6 +146,8 @@ const state = {
90
  sessionConfiguration: null,
91
  lastCapturedPhrase: [],
92
  lastGeneratedPhrase: null,
 
 
93
  historyItems: [],
94
  memoryItems: [],
95
  savedSessions: [],
@@ -104,10 +162,98 @@ const state = {
104
  infiniteScheduleTimerId: null,
105
  infiniteAbortController: null,
106
  infiniteRunId: 0,
 
 
 
 
107
  };
108
 
109
- class BrowserSynth {
110
- constructor() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  this.context = null;
112
  this.master = null;
113
  this.activeVoices = new Map();
@@ -116,7 +262,7 @@ class BrowserSynth {
116
  async ensureContext() {
117
  if (!this.context) {
118
  this.context = new window.AudioContext();
119
- this.master = new window.GainNode(this.context, { gain: 0.18 });
120
  this.master.connect(this.context.destination);
121
  }
122
  if (this.context.state === "suspended") {
@@ -124,12 +270,17 @@ class BrowserSynth {
124
  }
125
  }
126
 
127
- key(note, channel) {
128
- return `${channel}:${note}`;
 
129
  }
130
 
131
- midiToFrequency(note) {
132
- return 440 * 2 ** ((note - 69) / 12);
 
 
 
 
133
  }
134
 
135
  noteOn(note, channel, velocity) {
@@ -139,21 +290,9 @@ class BrowserSynth {
139
 
140
  const at = this.context.currentTime + 0.001;
141
  const key = this.key(note, channel);
142
- const oscillator = new OscillatorNode(this.context, {
143
- type: "triangle",
144
- frequency: this.midiToFrequency(note),
145
- });
146
- const gain = new GainNode(this.context, { gain: 0.0001 });
147
- oscillator.connect(gain).connect(this.master);
148
- gain.gain.setValueAtTime(0.0001, at);
149
- gain.gain.exponentialRampToValueAtTime(
150
- Math.max(0.03, (velocity / 127) * 0.2),
151
- at + 0.015,
152
- );
153
- oscillator.start(at);
154
-
155
  const existing = this.activeVoices.get(key) || [];
156
- existing.push({ oscillator, gain });
157
  this.activeVoices.set(key, existing);
158
  }
159
 
@@ -168,32 +307,358 @@ class BrowserSynth {
168
  return;
169
  }
170
 
171
- const at = this.context.currentTime + 0.001;
172
  const voice = voices.shift();
173
- voice.gain.gain.cancelScheduledValues(at);
174
- voice.gain.gain.setValueAtTime(Math.max(0.0001, voice.gain.gain.value), at);
175
- voice.gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.08);
176
- voice.oscillator.stop(at + 0.1);
177
  if (!voices.length) {
178
  this.activeVoices.delete(key);
179
  }
180
  }
181
 
182
- stop() {
183
- if (!this.context) {
 
184
  return;
185
  }
 
 
 
 
 
 
 
186
 
187
  const at = this.context.currentTime + 0.001;
 
188
  for (const voices of this.activeVoices.values()) {
189
  for (const voice of voices) {
190
- voice.gain.gain.cancelScheduledValues(at);
191
- voice.gain.gain.setValueAtTime(Math.max(0.0001, voice.gain.gain.value), at);
192
- voice.gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.04);
193
- voice.oscillator.stop(at + 0.06);
194
  }
195
  }
196
  this.activeVoices.clear();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  }
198
  }
199
 
@@ -308,7 +773,171 @@ class PhraseRecorder {
308
  }
309
  }
310
 
311
- const synth = new BrowserSynth();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  const recorder = new PhraseRecorder(
313
  PHRASE_TIMEOUT_MS,
314
  (events, completed) => {
@@ -316,10 +945,9 @@ const recorder = new PhraseRecorder(
316
  renderCapturedStats(events, notes, completed);
317
  },
318
  async (phrase) => {
319
- state.lastCapturedPhrase = phrase;
320
  const notes = eventsToNotes(phrase);
321
  renderCapturedStats(phrase, notes, true);
322
- updateInfiniteActionState();
323
  setPhraseMessage(
324
  `Phrase complete: ${phrase.length} events / ${notes.length} notes captured.`,
325
  );
@@ -352,6 +980,60 @@ function pluralize(value, singular, plural = `${singular}s`) {
352
  return `${value} ${value === 1 ? singular : plural}`;
353
  }
354
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  function renderAccountTrigger() {
356
  if (state.authUser) {
357
  const username = state.authUser.username;
@@ -474,6 +1156,14 @@ function normalizedKeepLastInputs(value) {
474
  return Math.min(500, Math.max(1, Math.round(parsed)));
475
  }
476
 
 
 
 
 
 
 
 
 
477
  function normalizedContinuationNoteCount(value) {
478
  if (value == null || value === "") {
479
  return null;
@@ -485,6 +1175,10 @@ function normalizedContinuationNoteCount(value) {
485
  return Math.max(1, Math.round(parsed));
486
  }
487
 
 
 
 
 
488
  function validateAuthCredentials() {
489
  const username = elements.authUsernameInput.value.trim();
490
  const password = elements.authPasswordInput.value;
@@ -540,8 +1234,15 @@ function continuationDurationMs(payload) {
540
  );
541
  }
542
 
 
 
 
 
 
 
 
543
  function infiniteLookaheadMs(payload) {
544
- const durationMs = continuationDurationMs(payload);
545
  return Math.min(
546
  INFINITE_MAX_LOOKAHEAD_MS,
547
  Math.max(INFINITE_MIN_LOOKAHEAD_MS, Math.round(durationMs * 0.4)),
@@ -553,18 +1254,20 @@ function readSessionSettingsFromControls() {
553
  learn_input: elements.learnInputToggle.checked,
554
  transposition: elements.transposeToggle.checked,
555
  forget_past: elements.forgetToggle.checked,
 
556
  keep_last_inputs: normalizedKeepLastInputs(elements.keepLastInput.value),
557
  decay_mode: elements.decayModeSelect.value,
558
  };
559
  }
560
 
561
  function describeSessionSettings(settings) {
 
562
  const transposeLabel = settings.transposition ? "Transpose on" : "Transpose off";
563
  const memoryLabel = settings.forget_past
564
  ? `Keep last ${settings.keep_last_inputs} phrases`
565
  : "Keep full memory";
566
  const decayLabel = `Decay ${settings.decay_mode}`;
567
- return [transposeLabel, memoryLabel, decayLabel];
568
  }
569
 
570
  function renderSessionSettingsSummary() {
@@ -588,6 +1291,9 @@ function syncSettingsControls(configuration) {
588
  elements.learnInputToggle.checked = configuration.learn_input;
589
  elements.transposeToggle.checked = configuration.transposition;
590
  elements.forgetToggle.checked = configuration.forget_past;
 
 
 
591
  elements.keepLastInput.value = String(configuration.keep_last_inputs);
592
  elements.decayModeSelect.value = configuration.decay_mode;
593
  updateKeepLastFieldState();
@@ -625,15 +1331,13 @@ function formatTimestamp(value) {
625
 
626
  function clearPhraseBuffers() {
627
  stopInfiniteMode({ stopPlayback: true, silent: true });
628
- state.lastCapturedPhrase = [];
629
- state.lastGeneratedPhrase = null;
630
  state.previewedHistoryIndex = null;
631
  state.previewedMemoryIndex = null;
632
  recorder.reset();
633
  renderCapturedStats([], [], false);
634
  renderGeneratedStats(null);
635
  syncPreviewSelection();
636
- updateInfiniteActionState();
637
  setPhraseStatus("Waiting for MIDI");
638
  }
639
 
@@ -795,13 +1499,12 @@ function revealPreviewTarget(kind) {
795
 
796
  function previewPhrasePayload(payload, kind, message) {
797
  if (kind === "generated") {
798
- state.lastGeneratedPhrase = payload;
799
  renderGeneratedStats(payload);
800
  } else {
801
- state.lastCapturedPhrase = payload.events;
802
  renderCapturedStats(payload.events, payload.notes, true);
803
  }
804
- updateInfiniteActionState();
805
  setPhraseMessage(message);
806
  revealPreviewTarget(kind);
807
  }
@@ -884,6 +1587,7 @@ function createMemorySummaryMarkup(memory) {
884
  if (memory.summary.seeded_phrase_count) {
885
  chips.push(`${memory.summary.seeded_phrase_count} seed`);
886
  }
 
887
  chips.push(memory.configuration.transposition ? "Transpose on" : "Transpose off");
888
  chips.push(
889
  memory.configuration.forget_past
@@ -1328,7 +2032,12 @@ async function refreshSessionActivity() {
1328
  await Promise.all([refreshHistory(), refreshMemory()]);
1329
  }
1330
 
1331
- function buildContinuationRequestBody(phraseEvents, learnInput, signal = null) {
 
 
 
 
 
1332
  const continuationNoteCount = normalizedContinuationNoteCount(
1333
  elements.continuationLengthInput.value,
1334
  );
@@ -1336,6 +2045,7 @@ function buildContinuationRequestBody(phraseEvents, learnInput, signal = null) {
1336
  session_id: state.sessionId,
1337
  phrase: phraseEvents,
1338
  learn_input: learnInput,
 
1339
  };
1340
  if (continuationNoteCount != null) {
1341
  requestBody.continuation_note_count = continuationNoteCount;
@@ -1352,76 +2062,617 @@ function buildContinuationRequestBody(phraseEvents, learnInput, signal = null) {
1352
  };
1353
  }
1354
 
1355
- function defaultContinuationMessage(payload, continuationNoteCount) {
1356
- return (
1357
- payload.status_message ||
1358
- (continuationNoteCount == null
1359
- ? `Continuation generated: ${payload.generated_phrase.note_count} notes returned.`
1360
- : `Continuation generated: ${payload.generated_phrase.note_count} notes returned from a ${continuationNoteCount}-note request.`)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1361
  );
 
1362
  }
1363
 
1364
- function applyContinuationPayload(payload) {
1365
- state.lastCapturedPhrase = payload.input_phrase.events;
1366
- renderCapturedStats(payload.input_phrase.events, payload.input_phrase.notes, true);
1367
- state.lastGeneratedPhrase = payload.generated_phrase;
1368
- renderGeneratedStats(payload.generated_phrase);
1369
- updateInfiniteActionState();
1370
- }
1371
 
1372
- async function requestContinuationFromEvents(
1373
- phraseEvents,
1374
- {
1375
- learnInput = elements.learnInputToggle.checked,
1376
- statusLabel = "Sending",
1377
- signal = null,
1378
- } = {},
1379
- ) {
1380
- if (!phraseEvents?.length) {
1381
- throw new Error("No completed phrase is ready yet.");
1382
  }
1383
 
1384
- await ensureSession();
1385
- setPhraseStatus(statusLabel);
1386
- const { continuationNoteCount, fetchOptions } = buildContinuationRequestBody(
1387
- phraseEvents,
1388
- learnInput,
1389
- signal,
1390
- );
1391
- const payload = await requestJson("/api/continue", fetchOptions);
1392
- return { payload, continuationNoteCount };
1393
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1394
 
1395
- async function sendCurrentPhrase() {
1396
- stopInfiniteMode({ stopPlayback: true, silent: true });
1397
- const { payload, continuationNoteCount } = await requestContinuationFromEvents(
1398
- state.lastCapturedPhrase,
1399
- { learnInput: elements.learnInputToggle.checked },
1400
- );
1401
- applyContinuationPayload(payload);
1402
- if (payload.generated_phrase.event_count > 0) {
1403
- await playPayload(payload.generated_phrase);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1404
  }
1405
- await refreshSessionActivity();
1406
- await refreshSavedSessions();
1407
- setPhraseStatus(payload.generated_phrase.note_count ? "Generated" : "Primed");
1408
- setPhraseMessage(defaultContinuationMessage(payload, continuationNoteCount));
 
 
 
 
 
 
 
 
1409
  }
1410
 
1411
- async function checkServer() {
1412
- const payload = await requestJson("/health");
1413
- elements.serverStatus.textContent = payload.ok
1414
- ? payload.seeded
1415
- ? "Healthy / seeded"
1416
- : "Healthy / empty memory"
1417
- : "Unavailable";
1418
  }
1419
 
1420
- function selectedMidiOutput() {
1421
- if (!state.midiAccess) {
1422
- return null;
 
 
 
 
 
 
 
 
 
 
 
1423
  }
1424
- return state.midiAccess.outputs.get(elements.midiOutputSelect.value) || null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1425
  }
1426
 
1427
  function outputNoteKey(note, channel) {
@@ -1436,6 +2687,50 @@ function sendOutputMessage(output, message) {
1436
  }
1437
  }
1438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1439
  function stopActivePlayback() {
1440
  const playback = state.activePlayback;
1441
  if (!playback) {
@@ -1445,51 +2740,16 @@ function stopActivePlayback() {
1445
  playback.timerIds.forEach((timerId) => {
1446
  window.clearTimeout(timerId);
1447
  });
1448
-
1449
- if (playback.output) {
1450
- playback.activeOutputNotes.forEach((key) => {
1451
- const [channelRaw, noteRaw] = key.split(":");
1452
- const channel = Number(channelRaw);
1453
- const note = Number(noteRaw);
1454
- sendOutputMessage(playback.output, [0x80 | (channel & 0x0f), note, 0]);
1455
- });
1456
-
1457
- for (let channel = 0; channel < 16; channel += 1) {
1458
- sendOutputMessage(playback.output, [0xb0 | channel, 64, 0]);
1459
- sendOutputMessage(playback.output, [0xb0 | channel, 123, 0]);
1460
- sendOutputMessage(playback.output, [0xb0 | channel, 120, 0]);
1461
- }
1462
- }
1463
-
1464
- synth.stop();
1465
  state.activePlayback = null;
1466
  updateInfiniteActionState();
1467
  return true;
1468
  }
1469
 
1470
  function dispatchPlaybackEvent(playback, event) {
1471
- if (playback.output) {
1472
- const status =
1473
- event.type === "note_on" && event.velocity > 0
1474
- ? 0x90 | (event.channel & 0x0f)
1475
- : 0x80 | (event.channel & 0x0f);
1476
- sendOutputMessage(playback.output, [status, event.note, event.velocity]);
1477
-
1478
- const key = outputNoteKey(event.note, event.channel);
1479
- if (event.type === "note_on" && event.velocity > 0) {
1480
- playback.activeOutputNotes.add(key);
1481
- } else {
1482
- playback.activeOutputNotes.delete(key);
1483
- }
1484
- return;
1485
- }
1486
-
1487
- if (event.type === "note_on" && event.velocity > 0) {
1488
- synth.noteOn(event.note, event.channel, event.velocity);
1489
- return;
1490
- }
1491
-
1492
- synth.noteOff(event.note, event.channel);
1493
  }
1494
 
1495
  async function playPayload(
@@ -1506,26 +2766,7 @@ async function playPayload(
1506
  let playback = state.activePlayback;
1507
  if (!append || !playback) {
1508
  stopActivePlayback();
1509
-
1510
- let output = null;
1511
- if (elements.midiOutputSelect.value !== BROWSER_SYNTH_ID) {
1512
- output = selectedMidiOutput();
1513
- if (output) {
1514
- await output.open();
1515
- }
1516
- }
1517
-
1518
- if (!output) {
1519
- await synth.ensureContext();
1520
- }
1521
-
1522
- playback = {
1523
- output,
1524
- timerIds: new Set(),
1525
- activeOutputNotes: new Set(),
1526
- cleanupTimerId: null,
1527
- endsAtMs: performance.now(),
1528
- };
1529
  state.activePlayback = playback;
1530
  }
1531
 
@@ -1537,6 +2778,7 @@ async function playPayload(
1537
 
1538
  const scheduleDelayMs = Math.max(0, startDelayMs);
1539
  const scheduleBaseMs = performance.now();
 
1540
  let cursorMs = 0;
1541
  for (const event of payload.events) {
1542
  cursorMs += event.delta_seconds * 1000;
@@ -1559,6 +2801,10 @@ async function playPayload(
1559
  }, scheduleDelayMs + cursorMs + 200);
1560
  playback.cleanupTimerId = cleanupTimerId;
1561
  playback.timerIds.add(cleanupTimerId);
 
 
 
 
1562
  playback.endsAtMs = Math.max(playback.endsAtMs, scheduleBaseMs + scheduleDelayMs + cursorMs);
1563
  updateInfiniteActionState();
1564
  }
@@ -1603,12 +2849,14 @@ function stopLoopAndPlayback(message) {
1603
 
1604
  if (hadInfiniteState) {
1605
  stopInfiniteMode({ stopPlayback: true, silent: true });
 
1606
  setPhraseStatus(state.lastCapturedPhrase.length ? "Phrase ready" : "Waiting for MIDI");
1607
  setPhraseMessage(message || "Infinite mode stopped.");
1608
  return true;
1609
  }
1610
 
1611
  if (stopActivePlayback()) {
 
1612
  setPhraseStatus(state.lastCapturedPhrase.length ? "Phrase ready" : "Waiting for MIDI");
1613
  setPhraseMessage(message || "Playback stopped.");
1614
  return true;
@@ -1624,8 +2872,8 @@ function scheduleInfiniteStep(prefixPayload, runId) {
1624
 
1625
  clearInfiniteScheduler();
1626
  const remainingPlaybackMs = state.activePlayback
1627
- ? Math.max(0, state.activePlayback.endsAtMs - performance.now())
1628
- : continuationDurationMs(prefixPayload);
1629
  const delayMs = Math.max(0, remainingPlaybackMs - infiniteLookaheadMs(prefixPayload));
1630
 
1631
  state.infiniteScheduleTimerId = window.setTimeout(() => {
@@ -1648,30 +2896,70 @@ async function runInfiniteStep(prefixEvents, runId) {
1648
  state.infiniteAbortController = abortController;
1649
  updateInfiniteActionState();
1650
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1651
  try {
1652
  const { payload } = await requestContinuationFromEvents(prefixEvents, {
1653
  learnInput: false,
1654
  statusLabel: state.activePlayback ? "Queueing next" : "Sending",
1655
  signal: abortController.signal,
 
1656
  });
1657
  if (!state.infiniteModeEnabled || runId !== state.infiniteRunId) {
1658
  return;
1659
  }
1660
 
1661
- applyContinuationPayload(payload);
1662
- if (!payload.generated_phrase.event_count) {
1663
- stopInfiniteMode({
1664
- stopPlayback: false,
1665
- message: "Infinite mode stopped because the latest continuation was empty.",
1666
- });
1667
- setPhraseStatus("Primed");
1668
  return;
1669
  }
1670
 
1671
  const startDelayMs = state.activePlayback
1672
- ? Math.max(0, state.activePlayback.endsAtMs - performance.now())
1673
  : PLAYBACK_START_DELAY_MS;
1674
- await playPayload(payload.generated_phrase, {
1675
  startDelayMs,
1676
  append: Boolean(state.activePlayback),
1677
  });
@@ -1683,13 +2971,25 @@ async function runInfiniteStep(prefixEvents, runId) {
1683
 
1684
  setPhraseStatus("Infinite");
1685
  setPhraseMessage(
1686
- `Infinite mode running: ${payload.generated_phrase.note_count} notes queued from the latest continuation.`,
1687
  );
1688
- scheduleInfiniteStep(payload.generated_phrase, runId);
1689
  } catch (error) {
1690
  if (error.name === "AbortError") {
1691
  return;
1692
  }
 
 
 
 
 
 
 
 
 
 
 
 
1693
  if (runId === state.infiniteRunId) {
1694
  stopInfiniteMode({
1695
  stopPlayback: false,
@@ -1723,9 +3023,7 @@ async function startInfiniteMode() {
1723
  return;
1724
  }
1725
 
1726
- const seedEvents = state.lastCapturedPhrase.length
1727
- ? state.lastCapturedPhrase
1728
- : state.lastGeneratedPhrase?.events || [];
1729
  if (!seedEvents.length) {
1730
  setPhraseMessage("Play or preview a phrase before starting infinite mode.", true);
1731
  return;
@@ -1746,13 +3044,18 @@ async function startInfiniteMode() {
1746
  const { payload } = await requestContinuationFromEvents(seedEvents, {
1747
  learnInput: elements.learnInputToggle.checked,
1748
  signal: abortController.signal,
 
1749
  });
1750
  if (!state.infiniteModeEnabled || runId !== state.infiniteRunId) {
1751
  return;
1752
  }
1753
 
1754
- applyContinuationPayload(payload);
1755
- if (!payload.generated_phrase.event_count) {
 
 
 
 
1756
  stopInfiniteMode({
1757
  stopPlayback: false,
1758
  message: "Infinite mode stopped because the first continuation was empty.",
@@ -1761,7 +3064,7 @@ async function startInfiniteMode() {
1761
  return;
1762
  }
1763
 
1764
- await playPayload(payload.generated_phrase);
1765
  await refreshSessionActivity();
1766
  await refreshSavedSessions();
1767
  if (!state.infiniteModeEnabled || runId !== state.infiniteRunId) {
@@ -1770,9 +3073,9 @@ async function startInfiniteMode() {
1770
 
1771
  setPhraseStatus("Infinite");
1772
  setPhraseMessage(
1773
- `Infinite mode running: ${payload.generated_phrase.note_count} notes in the first continuation.`,
1774
  );
1775
- scheduleInfiniteStep(payload.generated_phrase, runId);
1776
  } catch (error) {
1777
  if (error.name === "AbortError") {
1778
  return;
@@ -1793,15 +3096,59 @@ async function startInfiniteMode() {
1793
  }
1794
  }
1795
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1796
  async function populateMidiSelectors() {
 
1797
  if (!state.midiAccess) {
1798
  return;
1799
  }
1800
 
1801
  const inputs = [...state.midiAccess.inputs.values()];
1802
- const outputs = [...state.midiAccess.outputs.values()];
1803
  const previousInputId = elements.midiInputSelect.value;
1804
- const previousOutputId = elements.midiOutputSelect.value;
1805
 
1806
  elements.midiInputSelect.disabled = false;
1807
  elements.midiInputSelect.innerHTML = inputs.length
@@ -1813,23 +3160,6 @@ async function populateMidiSelectors() {
1813
  .join("")
1814
  : `<option value="">No MIDI inputs found</option>`;
1815
 
1816
- elements.midiOutputSelect.disabled = false;
1817
- const outputOptions = [
1818
- `<option value="${BROWSER_SYNTH_ID}">Browser Synth</option>`,
1819
- ...outputs.map(
1820
- (output) =>
1821
- `<option value="${output.id}">${output.name || output.id}</option>`,
1822
- ),
1823
- ];
1824
- elements.midiOutputSelect.innerHTML = outputOptions.join("");
1825
- elements.midiOutputSelect.value =
1826
- previousOutputId &&
1827
- (previousOutputId === BROWSER_SYNTH_ID ||
1828
- state.midiAccess.outputs.has(previousOutputId))
1829
- ? previousOutputId
1830
- : BROWSER_SYNTH_ID;
1831
- updateSelectedOutput();
1832
-
1833
  if (inputs.length) {
1834
  const inputId = state.midiAccess.inputs.has(previousInputId)
1835
  ? previousInputId
@@ -1919,14 +3249,20 @@ async function connectMidi() {
1919
  }
1920
 
1921
  function updateSelectedOutput() {
1922
- const outputId = elements.midiOutputSelect.value;
1923
- if (outputId === BROWSER_SYNTH_ID) {
1924
- setSelectedOutputName("Browser Synth");
1925
- return;
 
 
 
 
1926
  }
1927
 
1928
- const output = selectedMidiOutput();
1929
- setSelectedOutputName(output ? output.name || output.id : "Unavailable output");
 
 
1930
  }
1931
 
1932
  function clearPhrases() {
@@ -2102,6 +3438,15 @@ function bindEvents() {
2102
  }
2103
  });
2104
 
 
 
 
 
 
 
 
 
 
2105
  elements.replayGeneratedButton.addEventListener("click", async () => {
2106
  if (!state.lastGeneratedPhrase) {
2107
  setPhraseMessage("No generated phrase is available yet.", true);
@@ -2152,16 +3497,59 @@ function bindEvents() {
2152
 
2153
  elements.midiOutputSelect.addEventListener("change", async () => {
2154
  updateSelectedOutput();
2155
- const output = selectedMidiOutput();
2156
- if (output) {
2157
- try {
 
 
 
 
 
2158
  await output.open();
2159
- } catch (error) {
2160
- setPhraseMessage(error.message, true);
2161
- return;
 
 
2162
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2163
  }
2164
- setPhraseMessage(`Playback output set to ${elements.selectedOutputName.textContent}.`);
2165
  });
2166
 
2167
  elements.learnInputToggle.addEventListener("change", () => {
@@ -2172,6 +3560,13 @@ function bindEvents() {
2172
  renderSessionSettingsSummary();
2173
  });
2174
 
 
 
 
 
 
 
 
2175
  elements.forgetToggle.addEventListener("change", () => {
2176
  updateKeepLastFieldState();
2177
  renderSessionSettingsSummary();
@@ -2196,6 +3591,36 @@ function bindEvents() {
2196
  noteCount == null ? "" : String(noteCount);
2197
  });
2198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2199
  window.addEventListener("resize", () => {
2200
  drawPianoRoll(
2201
  elements.inputRoll,
@@ -2209,13 +3634,20 @@ function bindEvents() {
2209
 
2210
  async function initialize() {
2211
  bindEvents();
 
 
 
 
 
 
2212
  syncAuthUI();
2213
  renderSavedSessions([]);
2214
  clearCurrentSessionState();
2215
  setControlView("perform");
2216
  setActivityView("history");
2217
  setSelectedInputName("No MIDI input selected");
2218
- setSelectedOutputName("Browser Synth");
 
2219
  setLastMidiEvent("None yet");
2220
  updateKeepLastFieldState();
2221
  renderSessionSettingsSummary();
 
1
+ const DEFAULT_PLAYBACK_CHOICE = "browser_triangle";
2
+ const FAUST_CLAVIER_ID = "faust_clavier";
3
+ const FAUST_CUSTOM_ID = "faust_custom";
4
+ const WEB_MIDI_RENDERER_ID = "web_midi";
5
+ const PLAYBACK_CHOICE_SEPARATOR = "::";
6
  const PHRASE_TIMEOUT_MS = 1000;
7
  const PLAYBACK_START_DELAY_MS = 80;
8
  const INFINITE_MIN_LOOKAHEAD_MS = 600;
9
  const INFINITE_MAX_LOOKAHEAD_MS = 2200;
10
+ const FAUST_WASM_ESM_URL = "/assets/vendor/faustwasm/esm/index.js";
11
+ const FAUST_WASM_JS_URL = "/assets/vendor/faustwasm/libfaust-wasm/libfaust-wasm.js";
12
+ const FAUST_WASM_DATA_URL = "/assets/vendor/faustwasm/libfaust-wasm/libfaust-wasm.data";
13
+ const FAUST_WASM_BINARY_URL = "/assets/vendor/faustwasm/libfaust-wasm/libfaust-wasm.wasm";
14
+ const FAUST_CLAVIER_DSP_URL = "/assets/faust/continuator-clavier.dsp";
15
+ const FAUST_CUSTOM_TEMPLATE_DSP_URL = "/assets/faust/custom-poly-template.dsp";
16
+ const FAUST_CUSTOM_SOURCE_STORAGE_KEY = "continuator.faust.custom.source";
17
+ const FAUST_CUSTOM_VALUES_STORAGE_KEY = "continuator.faust.custom.values";
18
+ const FAUST_UI_CONTROL_TYPES = new Set([
19
+ "hslider",
20
+ "vslider",
21
+ "nentry",
22
+ "checkbox",
23
+ "button",
24
+ ]);
25
+ const FAUST_POLY_RESERVED_SUFFIXES = [
26
+ "/gate",
27
+ "/freq",
28
+ "/gain",
29
+ "/key",
30
+ "/vel",
31
+ "/velocity",
32
+ ];
33
 
34
  const elements = {
35
  serverStatus: document.querySelector("#server-status"),
 
87
  viewTabs: document.querySelectorAll("[data-view-tab]"),
88
  midiInputSelect: document.querySelector("#midi-input-select"),
89
  midiOutputSelect: document.querySelector("#midi-output-select"),
90
+ faustRendererPanel: document.querySelector("#faust-renderer-panel"),
91
+ faustClavierPanel: document.querySelector("#faust-clavier-panel"),
92
+ faustCustomPanel: document.querySelector("#faust-custom-panel"),
93
+ faustRendererStatus: document.querySelector("#faust-renderer-status"),
94
+ faustRendererHint: document.querySelector("#faust-renderer-hint"),
95
+ faustBrightnessInput: document.querySelector("#faust-brightness-input"),
96
+ faustBrightnessValue: document.querySelector("#faust-brightness-value"),
97
+ faustHardnessInput: document.querySelector("#faust-hardness-input"),
98
+ faustHardnessValue: document.querySelector("#faust-hardness-value"),
99
+ faustDampingInput: document.querySelector("#faust-damping-input"),
100
+ faustDampingValue: document.querySelector("#faust-damping-value"),
101
+ faustReleaseInput: document.querySelector("#faust-release-input"),
102
+ faustReleaseValue: document.querySelector("#faust-release-value"),
103
+ faustBodyInput: document.querySelector("#faust-body-input"),
104
+ faustBodyValue: document.querySelector("#faust-body-value"),
105
+ faustStereoInput: document.querySelector("#faust-stereo-input"),
106
+ faustStereoValue: document.querySelector("#faust-stereo-value"),
107
+ faustCustomCodeInput: document.querySelector("#faust-custom-code-input"),
108
+ faustCustomCompileButton: document.querySelector("#faust-custom-compile-button"),
109
+ faustCustomResetButton: document.querySelector("#faust-custom-reset-button"),
110
+ faustCustomDirtyState: document.querySelector("#faust-custom-dirty-state"),
111
+ faustCustomControls: document.querySelector("#faust-custom-controls"),
112
+ faustCustomControlsHint: document.querySelector("#faust-custom-controls-hint"),
113
  learnInputToggle: document.querySelector("#learn-input-toggle"),
114
  autoSendToggle: document.querySelector("#auto-send-toggle"),
115
  transposeToggle: document.querySelector("#transpose-toggle"),
116
  forgetToggle: document.querySelector("#forget-toggle"),
117
+ markovOrderInput: document.querySelector("#markov-order-input"),
118
  keepLastInput: document.querySelector("#keep-last-input"),
119
  decayModeSelect: document.querySelector("#decay-mode-select"),
120
  continuationLengthInput: document.querySelector("#continuation-length-input"),
 
123
  createSessionButton: document.querySelector("#create-session-button"),
124
  resetSessionButton: document.querySelector("#reset-session-button"),
125
  applySettingsButton: document.querySelector("#apply-settings-button"),
126
+ importMidiFilesButton: document.querySelector("#import-midi-files-button"),
127
+ importMidiFolderButton: document.querySelector("#import-midi-folder-button"),
128
+ midiImportInput: document.querySelector("#midi-import-input"),
129
+ midiFolderImportInput: document.querySelector("#midi-folder-import-input"),
130
  connectMidiButton: document.querySelector("#connect-midi-button"),
131
  refreshMidiButton: document.querySelector("#refresh-midi-button"),
132
  sendPhraseButton: document.querySelector("#send-phrase-button"),
133
+ generateMemoryButton: document.querySelector("#generate-memory-button"),
134
  replayGeneratedButton: document.querySelector("#replay-generated-button"),
135
  clearPhraseButton: document.querySelector("#clear-phrase-button"),
136
  };
 
146
  sessionConfiguration: null,
147
  lastCapturedPhrase: [],
148
  lastGeneratedPhrase: null,
149
+ lastCapturedAt: 0,
150
+ lastGeneratedAt: 0,
151
  historyItems: [],
152
  memoryItems: [],
153
  savedSessions: [],
 
162
  infiniteScheduleTimerId: null,
163
  infiniteAbortController: null,
164
  infiniteRunId: 0,
165
+ customFaustSource: "",
166
+ customFaustTemplateSource: "",
167
+ customFaustControlDescriptors: [],
168
+ customFaustControlBindings: [],
169
  };
170
 
171
+ function midiToFrequency(note) {
172
+ return 440 * 2 ** ((note - 69) / 12);
173
+ }
174
+
175
+ function createTriangleVoice(context, destination, note, velocity, at) {
176
+ const oscillator = new OscillatorNode(context, {
177
+ type: "triangle",
178
+ frequency: midiToFrequency(note),
179
+ });
180
+ const gain = new GainNode(context, { gain: 0.0001 });
181
+ oscillator.connect(gain).connect(destination);
182
+ gain.gain.setValueAtTime(0.0001, at);
183
+ gain.gain.exponentialRampToValueAtTime(
184
+ Math.max(0.03, (velocity / 127) * 0.2),
185
+ at + 0.015,
186
+ );
187
+ oscillator.start(at);
188
+
189
+ return {
190
+ release(releaseAt, releaseSeconds = 0.08) {
191
+ gain.gain.cancelScheduledValues(releaseAt);
192
+ gain.gain.setValueAtTime(Math.max(0.0001, gain.gain.value), releaseAt);
193
+ gain.gain.exponentialRampToValueAtTime(0.0001, releaseAt + releaseSeconds);
194
+ oscillator.stop(releaseAt + releaseSeconds + 0.02);
195
+ },
196
+ };
197
+ }
198
+
199
+ function createElectricVoice(context, destination, note, velocity, at) {
200
+ const frequency = midiToFrequency(note);
201
+ const filter = new BiquadFilterNode(context, {
202
+ type: "lowpass",
203
+ frequency: 3200,
204
+ Q: 0.8,
205
+ });
206
+ const gain = new GainNode(context, { gain: 0.0001 });
207
+ const bodyGain = new GainNode(context, { gain: 0.82 });
208
+ const shimmerGain = new GainNode(context, { gain: 0.18 });
209
+ const bodyOscillator = new OscillatorNode(context, {
210
+ type: "triangle",
211
+ frequency,
212
+ });
213
+ const shimmerOscillator = new OscillatorNode(context, {
214
+ type: "sine",
215
+ frequency: frequency * 2,
216
+ });
217
+
218
+ bodyOscillator.connect(bodyGain).connect(filter);
219
+ shimmerOscillator.connect(shimmerGain).connect(filter);
220
+ filter.connect(gain).connect(destination);
221
+
222
+ const peak = Math.max(0.025, (velocity / 127) * 0.17);
223
+ const sustain = Math.max(0.015, peak * 0.55);
224
+ gain.gain.setValueAtTime(0.0001, at);
225
+ gain.gain.exponentialRampToValueAtTime(peak, at + 0.01);
226
+ gain.gain.exponentialRampToValueAtTime(sustain, at + 0.12);
227
+ filter.frequency.setValueAtTime(3200, at);
228
+ filter.frequency.exponentialRampToValueAtTime(1500, at + 0.18);
229
+
230
+ bodyOscillator.start(at);
231
+ shimmerOscillator.start(at);
232
+
233
+ return {
234
+ release(releaseAt, releaseSeconds = 0.16) {
235
+ gain.gain.cancelScheduledValues(releaseAt);
236
+ gain.gain.setValueAtTime(Math.max(0.0001, gain.gain.value), releaseAt);
237
+ gain.gain.exponentialRampToValueAtTime(0.0001, releaseAt + releaseSeconds);
238
+ bodyOscillator.stop(releaseAt + releaseSeconds + 0.03);
239
+ shimmerOscillator.stop(releaseAt + releaseSeconds + 0.03);
240
+ },
241
+ };
242
+ }
243
+
244
+ class BrowserAudioRenderer {
245
+ constructor({
246
+ id,
247
+ label,
248
+ masterGain = 0.18,
249
+ createVoice,
250
+ groupLabel = "Browser Renderers",
251
+ }) {
252
+ this.id = id;
253
+ this.label = label;
254
+ this.masterGain = masterGain;
255
+ this.createVoice = createVoice;
256
+ this.groupLabel = groupLabel;
257
  this.context = null;
258
  this.master = null;
259
  this.activeVoices = new Map();
 
262
  async ensureContext() {
263
  if (!this.context) {
264
  this.context = new window.AudioContext();
265
+ this.master = new GainNode(this.context, { gain: this.masterGain });
266
  this.master.connect(this.context.destination);
267
  }
268
  if (this.context.state === "suspended") {
 
270
  }
271
  }
272
 
273
+ async createPlaybackSession() {
274
+ await this.ensureContext();
275
+ return {};
276
  }
277
 
278
+ getDisplayName() {
279
+ return this.label;
280
+ }
281
+
282
+ key(note, channel) {
283
+ return `${channel}:${note}`;
284
  }
285
 
286
  noteOn(note, channel, velocity) {
 
290
 
291
  const at = this.context.currentTime + 0.001;
292
  const key = this.key(note, channel);
293
+ const voice = this.createVoice(this.context, this.master, note, velocity, at);
 
 
 
 
 
 
 
 
 
 
 
 
294
  const existing = this.activeVoices.get(key) || [];
295
+ existing.push(voice);
296
  this.activeVoices.set(key, existing);
297
  }
298
 
 
307
  return;
308
  }
309
 
 
310
  const voice = voices.shift();
311
+ voice.release(this.context.currentTime + 0.001);
 
 
 
312
  if (!voices.length) {
313
  this.activeVoices.delete(key);
314
  }
315
  }
316
 
317
+ dispatchEvent(playback, event) {
318
+ if (event.type === "note_on" && event.velocity > 0) {
319
+ this.noteOn(event.note, event.channel, event.velocity);
320
  return;
321
  }
322
+ this.noteOff(event.note, event.channel);
323
+ }
324
+
325
+ stopVoices(releaseSeconds = 0.04) {
326
+ if (!this.context) {
327
+ return false;
328
+ }
329
 
330
  const at = this.context.currentTime + 0.001;
331
+ const hadVoices = this.activeVoices.size > 0;
332
  for (const voices of this.activeVoices.values()) {
333
  for (const voice of voices) {
334
+ voice.release(at, releaseSeconds);
 
 
 
335
  }
336
  }
337
  this.activeVoices.clear();
338
+ return hadVoices;
339
+ }
340
+
341
+ stopPlayback() {
342
+ this.stopVoices(0.04);
343
+ }
344
+
345
+ panicPlayback() {
346
+ return this.stopVoices(0.02);
347
+ }
348
+
349
+ panicTarget() {
350
+ return this.stopVoices(0.02);
351
+ }
352
+ }
353
+
354
+ function walkFaustUi(items, visit) {
355
+ for (const item of items || []) {
356
+ if (Array.isArray(item?.items)) {
357
+ walkFaustUi(item.items, visit);
358
+ continue;
359
+ }
360
+ visit(item);
361
+ }
362
+ }
363
+
364
+ let faustApiPromise = null;
365
+ let faustCompilerBundlePromise = null;
366
+
367
+ function safeLocalStorageGet(key) {
368
+ try {
369
+ return window.localStorage?.getItem(key) ?? null;
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ function safeLocalStorageSet(key, value) {
376
+ try {
377
+ if (!window.localStorage) {
378
+ return;
379
+ }
380
+ if (value == null) {
381
+ window.localStorage.removeItem(key);
382
+ return;
383
+ }
384
+ window.localStorage.setItem(key, value);
385
+ } catch {
386
+ // Ignore private-browsing or storage-denied failures.
387
+ }
388
+ }
389
+
390
+ function createFaustParamPathMap(ui) {
391
+ const paramPaths = new Map();
392
+ walkFaustUi(ui, (item) => {
393
+ const shortname = String(item?.shortname || "");
394
+ const address = String(item?.address || "");
395
+ const label = String(item?.label || "");
396
+ if (!address) {
397
+ return;
398
+ }
399
+ paramPaths.set(address, address);
400
+ if (shortname && !paramPaths.has(shortname)) {
401
+ paramPaths.set(shortname, address);
402
+ }
403
+ if (label) {
404
+ const labelTail = label.split("/").pop() || label;
405
+ const simplifiedLabel = labelTail.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
406
+ if (labelTail && !paramPaths.has(labelTail)) {
407
+ paramPaths.set(labelTail, address);
408
+ }
409
+ if (simplifiedLabel && !paramPaths.has(simplifiedLabel)) {
410
+ paramPaths.set(simplifiedLabel, address);
411
+ }
412
+ const lowercaseLabel = simplifiedLabel.toLowerCase();
413
+ if (lowercaseLabel && !paramPaths.has(lowercaseLabel)) {
414
+ paramPaths.set(lowercaseLabel, address);
415
+ }
416
+ }
417
+ });
418
+ return paramPaths;
419
+ }
420
+
421
+ async function loadFaustApi() {
422
+ if (!faustApiPromise) {
423
+ faustApiPromise = import(FAUST_WASM_ESM_URL);
424
+ }
425
+ return faustApiPromise;
426
+ }
427
+
428
+ async function getFaustCompilerBundle() {
429
+ if (!faustCompilerBundlePromise) {
430
+ faustCompilerBundlePromise = (async () => {
431
+ const api = await loadFaustApi();
432
+ const faustModule = await api.instantiateFaustModuleFromFile(
433
+ FAUST_WASM_JS_URL,
434
+ FAUST_WASM_DATA_URL,
435
+ FAUST_WASM_BINARY_URL,
436
+ );
437
+ return {
438
+ api,
439
+ compiler: new api.FaustCompiler(new api.LibFaust(faustModule)),
440
+ };
441
+ })();
442
+ }
443
+ return faustCompilerBundlePromise;
444
+ }
445
+
446
+ class FaustPolyRenderer {
447
+ constructor({
448
+ id,
449
+ label,
450
+ dspUrl,
451
+ getCode = null,
452
+ voices = 24,
453
+ controlDefaults = {},
454
+ groupLabel = "Faust Instruments",
455
+ idleDetail = "Ready when you route playback here.",
456
+ }) {
457
+ this.id = id;
458
+ this.label = label;
459
+ this.dspUrl = dspUrl;
460
+ this.getCode = getCode;
461
+ this.voices = voices;
462
+ this.groupLabel = groupLabel;
463
+ this.context = null;
464
+ this.generator = null;
465
+ this.node = null;
466
+ this.codePromise = null;
467
+ this.initializationPromise = null;
468
+ this.paramPaths = new Map();
469
+ this.controlValues = new Map(Object.entries(controlDefaults));
470
+ this.ui = [];
471
+ this.status = "Idle";
472
+ this.statusDetail = idleDetail;
473
+ this.lastError = null;
474
+ this.lastCompiledCode = null;
475
+ }
476
+
477
+ getDisplayName() {
478
+ return this.label;
479
+ }
480
+
481
+ setStatus(status, detail) {
482
+ this.status = status;
483
+ if (detail != null) {
484
+ this.statusDetail = detail;
485
+ }
486
+ syncFaustRendererPanel();
487
+ }
488
+
489
+ async ensureContext() {
490
+ if (!this.context) {
491
+ this.context = new window.AudioContext();
492
+ }
493
+ if (this.context.state === "suspended") {
494
+ await this.context.resume();
495
+ }
496
+ }
497
+
498
+ async loadCode(forceRefresh = false) {
499
+ if (this.getCode) {
500
+ const code = await this.getCode();
501
+ if (!String(code || "").trim()) {
502
+ throw new Error("No Faust DSP source is available.");
503
+ }
504
+ return code;
505
+ }
506
+
507
+ if (!this.codePromise || forceRefresh) {
508
+ this.codePromise = fetch(this.dspUrl).then(async (response) => {
509
+ if (!response.ok) {
510
+ throw new Error(`Unable to load the Faust DSP (${response.status}).`);
511
+ }
512
+ return response.text();
513
+ });
514
+ }
515
+ return this.codePromise;
516
+ }
517
+
518
+ captureParamPaths(ui) {
519
+ this.paramPaths = createFaustParamPathMap(ui);
520
+ }
521
+
522
+ applyControlValues() {
523
+ if (!this.node) {
524
+ return;
525
+ }
526
+
527
+ this.controlValues.forEach((value, key) => {
528
+ const path = this.paramPaths.get(key);
529
+ if (path) {
530
+ this.node.setParamValue(path, Number(value));
531
+ }
532
+ });
533
+ }
534
+
535
+ getUi() {
536
+ return this.ui;
537
+ }
538
+
539
+ async ensureNode({ forceRecompile = false } = {}) {
540
+ await this.ensureContext();
541
+ if (this.node && !forceRecompile) {
542
+ return this.node;
543
+ }
544
+ if (this.initializationPromise) {
545
+ return this.initializationPromise;
546
+ }
547
+
548
+ const previousNode = this.node;
549
+ const previousGenerator = this.generator;
550
+ const previousUi = this.ui;
551
+ const previousParamPaths = this.paramPaths;
552
+ this.initializationPromise = (async () => {
553
+ this.lastError = null;
554
+ this.setStatus("Loading", `Compiling ${this.label}…`);
555
+ const [{ api, compiler }, code] = await Promise.all([
556
+ getFaustCompilerBundle(),
557
+ this.loadCode(forceRecompile),
558
+ ]);
559
+ const generator = new api.FaustPolyDspGenerator();
560
+ const compiledGenerator = await generator.compile(compiler, this.id, code, "-ftz 2");
561
+ if (!compiledGenerator) {
562
+ throw new Error("Faust compilation returned no renderer.");
563
+ }
564
+ const nextNode = await compiledGenerator.createNode(this.context, this.voices, this.id);
565
+ if (!nextNode) {
566
+ throw new Error("Faust could not create a playable WebAudio node.");
567
+ }
568
+ nextNode.connect(this.context.destination);
569
+ const nextUi = compiledGenerator.getUI();
570
+ const nextParamPaths = createFaustParamPathMap(nextUi);
571
+ this.generator = compiledGenerator;
572
+ this.node = nextNode;
573
+ this.ui = nextUi;
574
+ this.paramPaths = nextParamPaths;
575
+ this.lastCompiledCode = code;
576
+ this.applyControlValues();
577
+ if (previousNode && previousNode !== nextNode) {
578
+ previousNode.allNotesOff?.(true);
579
+ previousNode.disconnect?.();
580
+ }
581
+ this.setStatus("Ready", `${this.voices} polyphonic voices are ready.`);
582
+ return nextNode;
583
+ })()
584
+ .catch((error) => {
585
+ if (this.node && this.node !== previousNode) {
586
+ this.node.allNotesOff?.(true);
587
+ this.node.disconnect?.();
588
+ }
589
+ this.node = previousNode;
590
+ this.generator = previousGenerator;
591
+ this.ui = previousUi;
592
+ this.paramPaths = previousParamPaths;
593
+ this.lastError = error instanceof Error ? error : new Error(String(error));
594
+ this.setStatus("Error", this.lastError.message);
595
+ throw this.lastError;
596
+ })
597
+ .finally(() => {
598
+ this.initializationPromise = null;
599
+ });
600
+
601
+ return this.initializationPromise;
602
+ }
603
+
604
+ async prepare(options = {}) {
605
+ await this.ensureNode(options);
606
+ }
607
+
608
+ setControlValue(key, value) {
609
+ const numericValue = Number(value);
610
+ if (!Number.isFinite(numericValue)) {
611
+ return;
612
+ }
613
+
614
+ this.controlValues.set(key, numericValue);
615
+ const path = this.paramPaths.get(key);
616
+ if (this.node && path) {
617
+ this.node.setParamValue(path, numericValue);
618
+ }
619
+ syncFaustRendererPanel();
620
+ }
621
+
622
+ getControlValue(key) {
623
+ return Number(this.controlValues.get(key));
624
+ }
625
+
626
+ async createPlaybackSession() {
627
+ const node = await this.ensureNode();
628
+ return { node };
629
+ }
630
+
631
+ dispatchEvent(playback, event) {
632
+ if (!playback?.node) {
633
+ return;
634
+ }
635
+
636
+ if (event.type === "note_on" && event.velocity > 0) {
637
+ playback.node.keyOn(event.channel, event.note, event.velocity);
638
+ return;
639
+ }
640
+
641
+ playback.node.keyOff(event.channel, event.note, event.velocity || 0);
642
+ }
643
+
644
+ stopPlayback(playback) {
645
+ playback?.node?.allNotesOff?.(false);
646
+ }
647
+
648
+ panicPlayback(playback) {
649
+ if (!playback?.node) {
650
+ return false;
651
+ }
652
+ playback.node.allNotesOff?.(true);
653
+ return true;
654
+ }
655
+
656
+ panicTarget() {
657
+ if (!this.node) {
658
+ return false;
659
+ }
660
+ this.node.allNotesOff?.(true);
661
+ return true;
662
  }
663
  }
664
 
 
773
  }
774
  }
775
 
776
+ const browserPlaybackRenderers = [
777
+ new BrowserAudioRenderer({
778
+ id: "browser_triangle",
779
+ label: "Browser Triangle",
780
+ masterGain: 0.18,
781
+ createVoice: createTriangleVoice,
782
+ }),
783
+ new BrowserAudioRenderer({
784
+ id: "browser_electric",
785
+ label: "Browser Electric",
786
+ masterGain: 0.16,
787
+ createVoice: createElectricVoice,
788
+ }),
789
+ ];
790
+
791
+ const faustClavierRenderer = new FaustPolyRenderer({
792
+ id: FAUST_CLAVIER_ID,
793
+ label: "Faust Clavier",
794
+ dspUrl: FAUST_CLAVIER_DSP_URL,
795
+ voices: 24,
796
+ controlDefaults: {
797
+ brightness: 0.58,
798
+ hardness: 0.34,
799
+ damping: 0.42,
800
+ release: 0.95,
801
+ body: 0.33,
802
+ stereo: 0.45,
803
+ },
804
+ });
805
+
806
+ const customFaustRenderer = new FaustPolyRenderer({
807
+ id: FAUST_CUSTOM_ID,
808
+ label: "Custom Faust",
809
+ getCode: async () => {
810
+ if (state.customFaustSource.trim()) {
811
+ return state.customFaustSource;
812
+ }
813
+ return loadCustomFaustTemplate();
814
+ },
815
+ voices: 24,
816
+ idleDetail: "Paste or edit a polyphonic Faust instrument, then compile it locally.",
817
+ });
818
+
819
+ const faustClavierControls = [
820
+ {
821
+ key: "brightness",
822
+ input: elements.faustBrightnessInput,
823
+ output: elements.faustBrightnessValue,
824
+ format: (value) => Number(value).toFixed(2),
825
+ },
826
+ {
827
+ key: "hardness",
828
+ input: elements.faustHardnessInput,
829
+ output: elements.faustHardnessValue,
830
+ format: (value) => Number(value).toFixed(2),
831
+ },
832
+ {
833
+ key: "damping",
834
+ input: elements.faustDampingInput,
835
+ output: elements.faustDampingValue,
836
+ format: (value) => Number(value).toFixed(2),
837
+ },
838
+ {
839
+ key: "release",
840
+ input: elements.faustReleaseInput,
841
+ output: elements.faustReleaseValue,
842
+ format: (value) => `${Number(value).toFixed(2)} s`,
843
+ },
844
+ {
845
+ key: "body",
846
+ input: elements.faustBodyInput,
847
+ output: elements.faustBodyValue,
848
+ format: (value) => Number(value).toFixed(2),
849
+ },
850
+ {
851
+ key: "stereo",
852
+ input: elements.faustStereoInput,
853
+ output: elements.faustStereoValue,
854
+ format: (value) => Number(value).toFixed(2),
855
+ },
856
+ ];
857
+
858
+ const localPlaybackRenderers = [
859
+ ...browserPlaybackRenderers,
860
+ faustClavierRenderer,
861
+ customFaustRenderer,
862
+ ];
863
+
864
+ const webMidiRenderer = {
865
+ id: WEB_MIDI_RENDERER_ID,
866
+
867
+ async createPlaybackSession(targetId) {
868
+ const output = midiOutputById(targetId);
869
+ if (!output) {
870
+ throw new Error("Selected MIDI output is unavailable.");
871
+ }
872
+ await output.open();
873
+ return {
874
+ output,
875
+ targetId,
876
+ activeOutputNotes: new Set(),
877
+ };
878
+ },
879
+
880
+ getDisplayName(targetId) {
881
+ const output = midiOutputById(targetId);
882
+ return output ? output.name || output.id : "Unavailable MIDI output";
883
+ },
884
+
885
+ dispatchEvent(playback, event) {
886
+ const status =
887
+ event.type === "note_on" && event.velocity > 0
888
+ ? 0x90 | (event.channel & 0x0f)
889
+ : 0x80 | (event.channel & 0x0f);
890
+ sendOutputMessage(playback.output, [status, event.note, event.velocity]);
891
+
892
+ const key = outputNoteKey(event.note, event.channel);
893
+ if (event.type === "note_on" && event.velocity > 0) {
894
+ playback.activeOutputNotes.add(key);
895
+ } else {
896
+ playback.activeOutputNotes.delete(key);
897
+ }
898
+ },
899
+
900
+ stopPlayback(playback) {
901
+ if (!playback.output) {
902
+ return;
903
+ }
904
+
905
+ playback.activeOutputNotes.forEach((key) => {
906
+ const [channelRaw, noteRaw] = key.split(":");
907
+ const channel = Number(channelRaw);
908
+ const note = Number(noteRaw);
909
+ sendOutputMessage(playback.output, [0x80 | (channel & 0x0f), note, 0]);
910
+ });
911
+ sendMidiPanicToOutput(playback.output);
912
+ },
913
+
914
+ panicPlayback(playback) {
915
+ if (!playback?.output) {
916
+ return false;
917
+ }
918
+ return sendMidiPanicToOutput(playback.output);
919
+ },
920
+
921
+ async panicTarget(targetId) {
922
+ const output = midiOutputById(targetId);
923
+ if (!output) {
924
+ return false;
925
+ }
926
+ try {
927
+ await output.open();
928
+ } catch {
929
+ return false;
930
+ }
931
+ return sendMidiPanicToOutput(output);
932
+ },
933
+ };
934
+
935
+ const playbackRendererRegistry = new Map(
936
+ [...localPlaybackRenderers, webMidiRenderer].map((renderer) => [
937
+ renderer.id,
938
+ renderer,
939
+ ]),
940
+ );
941
  const recorder = new PhraseRecorder(
942
  PHRASE_TIMEOUT_MS,
943
  (events, completed) => {
 
945
  renderCapturedStats(events, notes, completed);
946
  },
947
  async (phrase) => {
948
+ rememberCapturedPhrase(phrase);
949
  const notes = eventsToNotes(phrase);
950
  renderCapturedStats(phrase, notes, true);
 
951
  setPhraseMessage(
952
  `Phrase complete: ${phrase.length} events / ${notes.length} notes captured.`,
953
  );
 
980
  return `${value} ${value === 1 ? singular : plural}`;
981
  }
982
 
983
+ function rememberCapturedPhrase(events) {
984
+ state.lastCapturedPhrase = Array.isArray(events) ? events : [];
985
+ state.lastCapturedAt = Date.now();
986
+ updateInfiniteActionState();
987
+ }
988
+
989
+ function rememberGeneratedPhrase(payload) {
990
+ state.lastGeneratedPhrase = payload || null;
991
+ state.lastGeneratedAt = Date.now();
992
+ updateInfiniteActionState();
993
+ }
994
+
995
+ function clearRememberedPhrases() {
996
+ state.lastCapturedPhrase = [];
997
+ state.lastGeneratedPhrase = null;
998
+ state.lastCapturedAt = 0;
999
+ state.lastGeneratedAt = 0;
1000
+ updateInfiniteActionState();
1001
+ }
1002
+
1003
+ function latestLoopSeedEvents() {
1004
+ const generatedEvents = state.lastGeneratedPhrase?.events || [];
1005
+ if (generatedEvents.length && state.lastGeneratedAt >= state.lastCapturedAt) {
1006
+ return generatedEvents;
1007
+ }
1008
+ if (state.lastCapturedPhrase.length) {
1009
+ return state.lastCapturedPhrase;
1010
+ }
1011
+ return generatedEvents;
1012
+ }
1013
+
1014
+ function preferredGenerationNoteCount(referenceEvents = null) {
1015
+ const requestedNoteCount = normalizedContinuationNoteCount(
1016
+ elements.continuationLengthInput.value,
1017
+ );
1018
+ if (requestedNoteCount != null) {
1019
+ return requestedNoteCount;
1020
+ }
1021
+
1022
+ if (referenceEvents?.length) {
1023
+ return Math.max(1, eventsToNotes(referenceEvents).length);
1024
+ }
1025
+
1026
+ if (state.lastGeneratedPhrase?.note_count) {
1027
+ return Math.max(1, Number(state.lastGeneratedPhrase.note_count));
1028
+ }
1029
+
1030
+ if (state.lastCapturedPhrase.length) {
1031
+ return Math.max(1, eventsToNotes(state.lastCapturedPhrase).length);
1032
+ }
1033
+
1034
+ return 12;
1035
+ }
1036
+
1037
  function renderAccountTrigger() {
1038
  if (state.authUser) {
1039
  const username = state.authUser.username;
 
1156
  return Math.min(500, Math.max(1, Math.round(parsed)));
1157
  }
1158
 
1159
+ function normalizedMarkovOrder(value) {
1160
+ const parsed = Number(value);
1161
+ if (!Number.isFinite(parsed)) {
1162
+ return 4;
1163
+ }
1164
+ return Math.min(16, Math.max(1, Math.round(parsed)));
1165
+ }
1166
+
1167
  function normalizedContinuationNoteCount(value) {
1168
  if (value == null || value === "") {
1169
  return null;
 
1175
  return Math.max(1, Math.round(parsed));
1176
  }
1177
 
1178
+ function hasMidiFileExtension(name) {
1179
+ return /\.(mid|midi)$/i.test(String(name || ""));
1180
+ }
1181
+
1182
  function validateAuthCredentials() {
1183
  const username = elements.authUsernameInput.value.trim();
1184
  const password = elements.authPasswordInput.value;
 
1234
  );
1235
  }
1236
 
1237
+ function continuationHandoffMs(payload) {
1238
+ if (payload?.handoff_seconds != null) {
1239
+ return Math.max(0, Math.round(Number(payload.handoff_seconds) * 1000));
1240
+ }
1241
+ return continuationDurationMs(payload);
1242
+ }
1243
+
1244
  function infiniteLookaheadMs(payload) {
1245
+ const durationMs = continuationHandoffMs(payload);
1246
  return Math.min(
1247
  INFINITE_MAX_LOOKAHEAD_MS,
1248
  Math.max(INFINITE_MIN_LOOKAHEAD_MS, Math.round(durationMs * 0.4)),
 
1254
  learn_input: elements.learnInputToggle.checked,
1255
  transposition: elements.transposeToggle.checked,
1256
  forget_past: elements.forgetToggle.checked,
1257
+ markov_order: normalizedMarkovOrder(elements.markovOrderInput.value),
1258
  keep_last_inputs: normalizedKeepLastInputs(elements.keepLastInput.value),
1259
  decay_mode: elements.decayModeSelect.value,
1260
  };
1261
  }
1262
 
1263
  function describeSessionSettings(settings) {
1264
+ const orderLabel = `K=${settings.markov_order}`;
1265
  const transposeLabel = settings.transposition ? "Transpose on" : "Transpose off";
1266
  const memoryLabel = settings.forget_past
1267
  ? `Keep last ${settings.keep_last_inputs} phrases`
1268
  : "Keep full memory";
1269
  const decayLabel = `Decay ${settings.decay_mode}`;
1270
+ return [orderLabel, transposeLabel, memoryLabel, decayLabel];
1271
  }
1272
 
1273
  function renderSessionSettingsSummary() {
 
1291
  elements.learnInputToggle.checked = configuration.learn_input;
1292
  elements.transposeToggle.checked = configuration.transposition;
1293
  elements.forgetToggle.checked = configuration.forget_past;
1294
+ elements.markovOrderInput.value = String(
1295
+ normalizedMarkovOrder(configuration.markov_order),
1296
+ );
1297
  elements.keepLastInput.value = String(configuration.keep_last_inputs);
1298
  elements.decayModeSelect.value = configuration.decay_mode;
1299
  updateKeepLastFieldState();
 
1331
 
1332
  function clearPhraseBuffers() {
1333
  stopInfiniteMode({ stopPlayback: true, silent: true });
1334
+ clearRememberedPhrases();
 
1335
  state.previewedHistoryIndex = null;
1336
  state.previewedMemoryIndex = null;
1337
  recorder.reset();
1338
  renderCapturedStats([], [], false);
1339
  renderGeneratedStats(null);
1340
  syncPreviewSelection();
 
1341
  setPhraseStatus("Waiting for MIDI");
1342
  }
1343
 
 
1499
 
1500
  function previewPhrasePayload(payload, kind, message) {
1501
  if (kind === "generated") {
1502
+ rememberGeneratedPhrase(payload);
1503
  renderGeneratedStats(payload);
1504
  } else {
1505
+ rememberCapturedPhrase(payload.events);
1506
  renderCapturedStats(payload.events, payload.notes, true);
1507
  }
 
1508
  setPhraseMessage(message);
1509
  revealPreviewTarget(kind);
1510
  }
 
1587
  if (memory.summary.seeded_phrase_count) {
1588
  chips.push(`${memory.summary.seeded_phrase_count} seed`);
1589
  }
1590
+ chips.push(`K=${memory.configuration.markov_order}`);
1591
  chips.push(memory.configuration.transposition ? "Transpose on" : "Transpose off");
1592
  chips.push(
1593
  memory.configuration.forget_past
 
2032
  await Promise.all([refreshHistory(), refreshMemory()]);
2033
  }
2034
 
2035
+ function buildContinuationRequestBody(
2036
+ phraseEvents,
2037
+ learnInput,
2038
+ signal = null,
2039
+ enforceEndConstraint = true,
2040
+ ) {
2041
  const continuationNoteCount = normalizedContinuationNoteCount(
2042
  elements.continuationLengthInput.value,
2043
  );
 
2045
  session_id: state.sessionId,
2046
  phrase: phraseEvents,
2047
  learn_input: learnInput,
2048
+ enforce_end_constraint: enforceEndConstraint,
2049
  };
2050
  if (continuationNoteCount != null) {
2051
  requestBody.continuation_note_count = continuationNoteCount;
 
2062
  };
2063
  }
2064
 
2065
+ function defaultContinuationMessage(payload, continuationNoteCount) {
2066
+ return (
2067
+ payload.status_message ||
2068
+ (continuationNoteCount == null
2069
+ ? `Continuation generated: ${payload.generated_phrase.note_count} notes returned.`
2070
+ : `Continuation generated: ${payload.generated_phrase.note_count} notes returned from a ${continuationNoteCount}-note request.`)
2071
+ );
2072
+ }
2073
+
2074
+ function applyContinuationPayload(payload) {
2075
+ rememberCapturedPhrase(payload.input_phrase.events);
2076
+ renderCapturedStats(payload.input_phrase.events, payload.input_phrase.notes, true);
2077
+ rememberGeneratedPhrase(payload.generated_phrase);
2078
+ renderGeneratedStats(payload.generated_phrase);
2079
+ }
2080
+
2081
+ function applyGeneratedPhrasePayload(payload) {
2082
+ rememberGeneratedPhrase(payload);
2083
+ renderGeneratedStats(payload);
2084
+ }
2085
+
2086
+ function defaultMidiImportMessage(payload) {
2087
+ const importedCount = Number(payload?.imported_file_count || 0);
2088
+ const skippedCount = Number(payload?.skipped_file_count || 0);
2089
+ const importedNames = Array.isArray(payload?.imported_files)
2090
+ ? payload.imported_files
2091
+ .map((item) => item?.file_name)
2092
+ .filter((value) => typeof value === "string" && value.length)
2093
+ : [];
2094
+
2095
+ let message = `Imported ${pluralize(importedCount, "MIDI file")} into the current session memory.`;
2096
+ if (importedNames.length) {
2097
+ const shownNames = importedNames.slice(0, 3).join(", ");
2098
+ const overflow = importedNames.length > 3 ? `, and ${importedNames.length - 3} more` : "";
2099
+ message += ` ${shownNames}${overflow}.`;
2100
+ }
2101
+ if (skippedCount) {
2102
+ message += ` Skipped ${pluralize(skippedCount, "file")} that were empty, unreadable, or not MIDI.`;
2103
+ }
2104
+ return message;
2105
+ }
2106
+
2107
+ function defaultMemoryGenerationMessage(payload, requestedNoteCount) {
2108
+ return (
2109
+ payload.status_message ||
2110
+ `Fresh phrase generated from memory: ${payload.generated_phrase.note_count} notes returned from a ${requestedNoteCount}-note request.`
2111
+ );
2112
+ }
2113
+
2114
+ async function requestContinuationFromEvents(
2115
+ phraseEvents,
2116
+ {
2117
+ learnInput = elements.learnInputToggle.checked,
2118
+ statusLabel = "Sending",
2119
+ signal = null,
2120
+ enforceEndConstraint = true,
2121
+ } = {},
2122
+ ) {
2123
+ if (!phraseEvents?.length) {
2124
+ throw new Error("No completed phrase is ready yet.");
2125
+ }
2126
+
2127
+ await ensureSession();
2128
+ setPhraseStatus(statusLabel);
2129
+ const { continuationNoteCount, fetchOptions } = buildContinuationRequestBody(
2130
+ phraseEvents,
2131
+ learnInput,
2132
+ signal,
2133
+ enforceEndConstraint,
2134
+ );
2135
+ const payload = await requestJson("/api/continue", fetchOptions);
2136
+ return { payload, continuationNoteCount };
2137
+ }
2138
+
2139
+ async function requestMemoryGeneration(
2140
+ {
2141
+ noteCount = preferredGenerationNoteCount(),
2142
+ statusLabel = "Generating",
2143
+ signal = null,
2144
+ enforceEndConstraint = true,
2145
+ } = {},
2146
+ ) {
2147
+ await ensureSession();
2148
+ setPhraseStatus(statusLabel);
2149
+ const requestBody = {
2150
+ note_count: noteCount,
2151
+ enforce_end_constraint: enforceEndConstraint,
2152
+ };
2153
+ const payload = await requestJson(`/api/sessions/${state.sessionId}/generate`, {
2154
+ method: "POST",
2155
+ headers: { "Content-Type": "application/json" },
2156
+ body: JSON.stringify(requestBody),
2157
+ signal,
2158
+ });
2159
+ return { payload, noteCount };
2160
+ }
2161
+
2162
+ async function sendCurrentPhrase() {
2163
+ stopInfiniteMode({ stopPlayback: true, silent: true });
2164
+ const { payload, continuationNoteCount } = await requestContinuationFromEvents(
2165
+ state.lastCapturedPhrase,
2166
+ { learnInput: elements.learnInputToggle.checked },
2167
+ );
2168
+ applyContinuationPayload(payload);
2169
+ if (payload.generated_phrase.event_count > 0) {
2170
+ await playPayload(payload.generated_phrase);
2171
+ }
2172
+ await refreshSessionActivity();
2173
+ await refreshSavedSessions();
2174
+ setPhraseStatus(payload.generated_phrase.note_count ? "Generated" : "Primed");
2175
+ setPhraseMessage(defaultContinuationMessage(payload, continuationNoteCount));
2176
+ }
2177
+
2178
+ async function generateFreshPhrase() {
2179
+ stopInfiniteMode({ stopPlayback: true, silent: true });
2180
+ const { payload, noteCount } = await requestMemoryGeneration({
2181
+ noteCount: preferredGenerationNoteCount(),
2182
+ statusLabel: "Generating",
2183
+ });
2184
+ applyGeneratedPhrasePayload(payload.generated_phrase);
2185
+ if (payload.generated_phrase.event_count > 0) {
2186
+ await playPayload(payload.generated_phrase);
2187
+ }
2188
+ await refreshSessionActivity();
2189
+ await refreshSavedSessions();
2190
+ setPhraseStatus(payload.generated_phrase.note_count ? "Generated" : "Primed");
2191
+ setPhraseMessage(defaultMemoryGenerationMessage(payload, noteCount));
2192
+ }
2193
+
2194
+ async function importSelectedMidiFiles(fileList, selectionLabel = "selection") {
2195
+ const selectedFiles = Array.from(fileList || []);
2196
+ if (!selectedFiles.length) {
2197
+ return;
2198
+ }
2199
+
2200
+ const midiFiles = selectedFiles.filter((file) =>
2201
+ hasMidiFileExtension(file.webkitRelativePath || file.name),
2202
+ );
2203
+ if (!midiFiles.length) {
2204
+ throw new Error(`No MIDI files were found in the selected ${selectionLabel}.`);
2205
+ }
2206
+
2207
+ stopInfiniteMode({ stopPlayback: true, silent: true });
2208
+ await ensureSession();
2209
+
2210
+ const formData = new FormData();
2211
+ midiFiles.forEach((file, index) => {
2212
+ const uploadName =
2213
+ file.webkitRelativePath || file.name || `imported_${index + 1}.mid`;
2214
+ formData.append("files", file, uploadName);
2215
+ });
2216
+
2217
+ setPhraseMessage(
2218
+ `Importing ${pluralize(midiFiles.length, "MIDI file")} into the current session memory...`,
2219
+ );
2220
+ const payload = await requestJson(`/api/sessions/${state.sessionId}/import-midi`, {
2221
+ method: "POST",
2222
+ body: formData,
2223
+ });
2224
+
2225
+ await refreshSessionActivity();
2226
+ await refreshSavedSessions();
2227
+ setControlView("continuator");
2228
+ setActivityView("memory");
2229
+ setSessionStatus("Ready");
2230
+ setPhraseStatus(state.lastCapturedPhrase.length ? "Phrase ready" : "Waiting for MIDI");
2231
+ setPhraseMessage(defaultMidiImportMessage(payload));
2232
+ }
2233
+
2234
+ async function checkServer() {
2235
+ const payload = await requestJson("/health");
2236
+ elements.serverStatus.textContent = payload.ok
2237
+ ? payload.seeded
2238
+ ? "Healthy / seeded"
2239
+ : "Healthy / empty memory"
2240
+ : "Unavailable";
2241
+ }
2242
+
2243
+ function midiOutputById(outputId) {
2244
+ if (!state.midiAccess || !outputId) {
2245
+ return null;
2246
+ }
2247
+ return state.midiAccess.outputs.get(outputId) || null;
2248
+ }
2249
+
2250
+ function selectedPlaybackChoiceValue() {
2251
+ return elements.midiOutputSelect.value || DEFAULT_PLAYBACK_CHOICE;
2252
+ }
2253
+
2254
+ function encodePlaybackChoice(rendererId, targetId = null) {
2255
+ return targetId
2256
+ ? `${rendererId}${PLAYBACK_CHOICE_SEPARATOR}${targetId}`
2257
+ : rendererId;
2258
+ }
2259
+
2260
+ function parsePlaybackChoice(choiceValue) {
2261
+ if (!choiceValue) {
2262
+ return {
2263
+ rendererId: DEFAULT_PLAYBACK_CHOICE,
2264
+ targetId: null,
2265
+ };
2266
+ }
2267
+
2268
+ const [rendererId, ...targetParts] = String(choiceValue).split(
2269
+ PLAYBACK_CHOICE_SEPARATOR,
2270
+ );
2271
+ return {
2272
+ rendererId: rendererId || DEFAULT_PLAYBACK_CHOICE,
2273
+ targetId: targetParts.length
2274
+ ? targetParts.join(PLAYBACK_CHOICE_SEPARATOR)
2275
+ : null,
2276
+ };
2277
+ }
2278
+
2279
+ function resolvePlaybackChoice(choiceValue = selectedPlaybackChoiceValue()) {
2280
+ const { rendererId, targetId } = parsePlaybackChoice(choiceValue);
2281
+ const renderer =
2282
+ playbackRendererRegistry.get(rendererId) ||
2283
+ playbackRendererRegistry.get(DEFAULT_PLAYBACK_CHOICE) ||
2284
+ null;
2285
+ return {
2286
+ value: encodePlaybackChoice(renderer?.id || rendererId, targetId),
2287
+ rendererId: renderer?.id || rendererId,
2288
+ targetId,
2289
+ renderer,
2290
+ };
2291
+ }
2292
+
2293
+ function selectedPlaybackChoice() {
2294
+ return resolvePlaybackChoice(selectedPlaybackChoiceValue());
2295
+ }
2296
+
2297
+ function playbackChoiceLabel(choiceValue = selectedPlaybackChoiceValue()) {
2298
+ const choice = resolvePlaybackChoice(choiceValue);
2299
+ return choice.renderer
2300
+ ? choice.renderer.getDisplayName(choice.targetId)
2301
+ : "Unavailable renderer";
2302
+ }
2303
+
2304
+ async function loadCustomFaustTemplate() {
2305
+ if (state.customFaustTemplateSource) {
2306
+ return state.customFaustTemplateSource;
2307
+ }
2308
+
2309
+ const response = await fetch(FAUST_CUSTOM_TEMPLATE_DSP_URL);
2310
+ if (!response.ok) {
2311
+ throw new Error(`Unable to load the Custom Faust starter template (${response.status}).`);
2312
+ }
2313
+ state.customFaustTemplateSource = await response.text();
2314
+ return state.customFaustTemplateSource;
2315
+ }
2316
+
2317
+ async function initializeCustomFaustSource() {
2318
+ const storedSource = safeLocalStorageGet(FAUST_CUSTOM_SOURCE_STORAGE_KEY);
2319
+ if (storedSource && storedSource.trim()) {
2320
+ state.customFaustSource = storedSource;
2321
+ elements.faustCustomCodeInput.value = storedSource;
2322
+ return;
2323
+ }
2324
+
2325
+ const template = await loadCustomFaustTemplate();
2326
+ state.customFaustSource = template;
2327
+ elements.faustCustomCodeInput.value = template;
2328
+ safeLocalStorageSet(FAUST_CUSTOM_SOURCE_STORAGE_KEY, template);
2329
+ }
2330
+
2331
+ function setCustomFaustSource(source, { persist = true } = {}) {
2332
+ state.customFaustSource = String(source ?? "");
2333
+ if (
2334
+ document.activeElement !== elements.faustCustomCodeInput &&
2335
+ elements.faustCustomCodeInput.value !== state.customFaustSource
2336
+ ) {
2337
+ elements.faustCustomCodeInput.value = state.customFaustSource;
2338
+ }
2339
+ if (persist) {
2340
+ safeLocalStorageSet(FAUST_CUSTOM_SOURCE_STORAGE_KEY, state.customFaustSource);
2341
+ }
2342
+ }
2343
+
2344
+ function selectedFaustRenderer(choiceValue = selectedPlaybackChoiceValue()) {
2345
+ const renderer = resolvePlaybackChoice(choiceValue).renderer;
2346
+ return renderer instanceof FaustPolyRenderer ? renderer : null;
2347
+ }
2348
+
2349
+ function selectedRendererIsFaust(choiceValue = selectedPlaybackChoiceValue()) {
2350
+ return Boolean(selectedFaustRenderer(choiceValue));
2351
+ }
2352
+
2353
+ function selectedRendererIsCustomFaust(choiceValue = selectedPlaybackChoiceValue()) {
2354
+ return resolvePlaybackChoice(choiceValue).rendererId === FAUST_CUSTOM_ID;
2355
+ }
2356
+
2357
+ function clampFaustControlValue(value, descriptor) {
2358
+ if (descriptor.kind !== "range") {
2359
+ return value >= 0.5 ? 1 : 0;
2360
+ }
2361
+ return Math.min(descriptor.max, Math.max(descriptor.min, value));
2362
+ }
2363
+
2364
+ function faustMetaMap(item) {
2365
+ const meta = {};
2366
+ for (const entry of item?.meta || []) {
2367
+ for (const [key, value] of Object.entries(entry || {})) {
2368
+ meta[key] = value;
2369
+ }
2370
+ }
2371
+ return meta;
2372
+ }
2373
+
2374
+ function faustLabel(item) {
2375
+ const rawLabel = String(item?.label || item?.shortname || item?.address || "Control");
2376
+ const label = rawLabel.split("/").pop() || rawLabel;
2377
+ const withSpaces = label.replace(/_/g, " ").trim();
2378
+ return withSpaces ? withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1) : "Control";
2379
+ }
2380
+
2381
+ function faustControlPrecision(step) {
2382
+ const numericStep = Number(step);
2383
+ if (!Number.isFinite(numericStep) || numericStep <= 0 || numericStep >= 1) {
2384
+ return 0;
2385
+ }
2386
+ return Math.min(4, Math.max(0, Math.ceil(-Math.log10(numericStep))));
2387
+ }
2388
+
2389
+ function formatFaustControlValue(descriptor, value) {
2390
+ if (descriptor.kind === "toggle") {
2391
+ return value >= 0.5 ? "On" : "Off";
2392
+ }
2393
+ const precision = faustControlPrecision(descriptor.step);
2394
+ const formatted = Number(value).toFixed(precision);
2395
+ return descriptor.unit ? `${formatted} ${descriptor.unit}` : formatted;
2396
+ }
2397
+
2398
+ function loadStoredCustomFaustValues() {
2399
+ const stored = safeLocalStorageGet(FAUST_CUSTOM_VALUES_STORAGE_KEY);
2400
+ if (!stored) {
2401
+ return {};
2402
+ }
2403
+ try {
2404
+ const parsed = JSON.parse(stored);
2405
+ return parsed && typeof parsed === "object" ? parsed : {};
2406
+ } catch {
2407
+ return {};
2408
+ }
2409
+ }
2410
+
2411
+ function persistCustomFaustValues() {
2412
+ if (!state.customFaustControlDescriptors.length) {
2413
+ safeLocalStorageSet(FAUST_CUSTOM_VALUES_STORAGE_KEY, null);
2414
+ return;
2415
+ }
2416
+
2417
+ const snapshot = {};
2418
+ for (const descriptor of state.customFaustControlDescriptors) {
2419
+ snapshot[descriptor.key] = customFaustRenderer.getControlValue(descriptor.key);
2420
+ }
2421
+ safeLocalStorageSet(FAUST_CUSTOM_VALUES_STORAGE_KEY, JSON.stringify(snapshot));
2422
+ }
2423
+
2424
+ function buildCustomFaustControlDescriptors(ui) {
2425
+ const storedValues = loadStoredCustomFaustValues();
2426
+ const descriptors = [];
2427
+ walkFaustUi(ui, (item) => {
2428
+ if (!FAUST_UI_CONTROL_TYPES.has(item?.type)) {
2429
+ return;
2430
+ }
2431
+
2432
+ const address = String(item?.address || "");
2433
+ const meta = faustMetaMap(item);
2434
+ if (!address || meta.hidden === "1") {
2435
+ return;
2436
+ }
2437
+ if (FAUST_POLY_RESERVED_SUFFIXES.some((suffix) => address.endsWith(suffix))) {
2438
+ return;
2439
+ }
2440
+
2441
+ const kind =
2442
+ item.type === "checkbox" || item.type === "button" ? "toggle" : "range";
2443
+ const descriptor = {
2444
+ key: address,
2445
+ kind,
2446
+ label: faustLabel(item),
2447
+ unit: String(meta.unit || ""),
2448
+ min: Number.isFinite(Number(item?.min)) ? Number(item.min) : 0,
2449
+ max: Number.isFinite(Number(item?.max)) ? Number(item.max) : 1,
2450
+ step:
2451
+ Number.isFinite(Number(item?.step)) && Number(item.step) > 0
2452
+ ? Number(item.step)
2453
+ : kind === "range"
2454
+ ? 0.01
2455
+ : 1,
2456
+ defaultValue: Number.isFinite(Number(item?.init)) ? Number(item.init) : 0,
2457
+ };
2458
+
2459
+ const existingValue = customFaustRenderer.controlValues.get(descriptor.key);
2460
+ const storedValue = storedValues[descriptor.key];
2461
+ const nextValue = Number.isFinite(Number(existingValue))
2462
+ ? Number(existingValue)
2463
+ : Number.isFinite(Number(storedValue))
2464
+ ? Number(storedValue)
2465
+ : descriptor.defaultValue;
2466
+ descriptor.value = clampFaustControlValue(nextValue, descriptor);
2467
+ descriptors.push(descriptor);
2468
+ });
2469
+
2470
+ customFaustRenderer.controlValues = new Map(
2471
+ descriptors.map((descriptor) => [descriptor.key, descriptor.value]),
2472
  );
2473
+ return descriptors;
2474
  }
2475
 
2476
+ function rebuildCustomFaustControls() {
2477
+ elements.faustCustomControls.replaceChildren();
2478
+ state.customFaustControlBindings = [];
 
 
 
 
2479
 
2480
+ if (!state.customFaustControlDescriptors.length) {
2481
+ return;
 
 
 
 
 
 
 
 
2482
  }
2483
 
2484
+ const fragment = document.createDocumentFragment();
2485
+ state.customFaustControlDescriptors.forEach((descriptor, index) => {
2486
+ const field = document.createElement("div");
2487
+ field.className = "field faust-field";
2488
+
2489
+ const labelRow = document.createElement("div");
2490
+ labelRow.className = "faust-label-row";
2491
+
2492
+ const label = document.createElement("label");
2493
+ const output = document.createElement("output");
2494
+ const inputId = `faust-custom-control-${index}`;
2495
+ label.setAttribute("for", inputId);
2496
+ output.setAttribute("for", inputId);
2497
+ label.textContent = descriptor.label;
2498
+
2499
+ let input;
2500
+ if (descriptor.kind === "toggle") {
2501
+ input = document.createElement("input");
2502
+ input.type = "checkbox";
2503
+ input.id = inputId;
2504
+ input.checked = descriptor.value >= 0.5;
2505
+ input.addEventListener("change", () => {
2506
+ const nextValue = input.checked ? 1 : 0;
2507
+ descriptor.value = nextValue;
2508
+ customFaustRenderer.setControlValue(descriptor.key, nextValue);
2509
+ persistCustomFaustValues();
2510
+ syncFaustRendererPanel();
2511
+ });
2512
+ } else {
2513
+ input = document.createElement("input");
2514
+ input.type = "range";
2515
+ input.id = inputId;
2516
+ input.min = String(descriptor.min);
2517
+ input.max = String(descriptor.max);
2518
+ input.step = String(descriptor.step);
2519
+ input.value = String(descriptor.value);
2520
+ input.addEventListener("input", () => {
2521
+ const nextValue = clampFaustControlValue(Number(input.value), descriptor);
2522
+ descriptor.value = nextValue;
2523
+ customFaustRenderer.setControlValue(descriptor.key, nextValue);
2524
+ persistCustomFaustValues();
2525
+ syncFaustRendererPanel();
2526
+ });
2527
+ }
2528
 
2529
+ output.textContent = formatFaustControlValue(descriptor, descriptor.value);
2530
+ labelRow.append(label, output);
2531
+ field.append(labelRow, input);
2532
+ fragment.append(field);
2533
+ state.customFaustControlBindings.push({ descriptor, input, output });
2534
+ });
2535
+
2536
+ elements.faustCustomControls.append(fragment);
2537
+ }
2538
+
2539
+ function syncCustomFaustControls() {
2540
+ const customSelected = selectedRendererIsCustomFaust();
2541
+ const inputsEnabled =
2542
+ customSelected &&
2543
+ customFaustRenderer.status !== "Loading" &&
2544
+ customFaustRenderer.status !== "Error";
2545
+ for (const binding of state.customFaustControlBindings) {
2546
+ const currentValue = customFaustRenderer.getControlValue(binding.descriptor.key);
2547
+ binding.descriptor.value = clampFaustControlValue(currentValue, binding.descriptor);
2548
+ if (binding.descriptor.kind === "toggle") {
2549
+ binding.input.checked = binding.descriptor.value >= 0.5;
2550
+ } else if (binding.input.value !== String(binding.descriptor.value)) {
2551
+ binding.input.value = String(binding.descriptor.value);
2552
+ }
2553
+ binding.input.disabled = !inputsEnabled;
2554
+ binding.output.textContent = formatFaustControlValue(
2555
+ binding.descriptor,
2556
+ binding.descriptor.value,
2557
+ );
2558
  }
2559
+
2560
+ elements.faustCustomControlsHint.hidden = state.customFaustControlBindings.length > 0;
2561
+ if (state.customFaustControlBindings.length) {
2562
+ return;
2563
+ }
2564
+ if (customFaustRenderer.status === "Ready") {
2565
+ elements.faustCustomControlsHint.textContent =
2566
+ "This DSP compiled successfully, but it does not expose any visible user parameters.";
2567
+ return;
2568
+ }
2569
+ elements.faustCustomControlsHint.textContent =
2570
+ "Compile a polyphonic instrument to expose its parameters.";
2571
  }
2572
 
2573
+ function customFaustHasPendingChanges() {
2574
+ return state.customFaustSource !== (customFaustRenderer.lastCompiledCode || "");
 
 
 
 
 
2575
  }
2576
 
2577
+ function customFaustDirtyCopy() {
2578
+ if (!state.customFaustSource.trim()) {
2579
+ return "Paste or write a Faust DSP before compiling.";
2580
+ }
2581
+ if (customFaustRenderer.status === "Loading") {
2582
+ return "Compiling the current source in your browser…";
2583
+ }
2584
+ if (customFaustHasPendingChanges()) {
2585
+ return customFaustRenderer.lastCompiledCode
2586
+ ? "Edits are saved locally. Compile to replace the last working build with these changes."
2587
+ : "Compile to turn this source into the active renderer.";
2588
+ }
2589
+ if (customFaustRenderer.status === "Ready") {
2590
+ return "The editor matches the currently active compiled renderer.";
2591
  }
2592
+ return "Compile to turn the latest source into the active renderer.";
2593
+ }
2594
+
2595
+ function refreshCustomFaustControlState() {
2596
+ state.customFaustControlDescriptors = buildCustomFaustControlDescriptors(
2597
+ customFaustRenderer.getUi(),
2598
+ );
2599
+ rebuildCustomFaustControls();
2600
+ persistCustomFaustValues();
2601
+ }
2602
+
2603
+ function syncFaustRendererPanel() {
2604
+ const selectedRenderer = selectedFaustRenderer();
2605
+ const faustSelected = Boolean(selectedRenderer);
2606
+ elements.faustRendererPanel.hidden = !faustSelected;
2607
+ elements.faustClavierPanel.hidden = !faustSelected || selectedRenderer?.id !== FAUST_CLAVIER_ID;
2608
+ elements.faustCustomPanel.hidden = !faustSelected || selectedRenderer?.id !== FAUST_CUSTOM_ID;
2609
+ elements.faustRendererStatus.textContent = selectedRenderer?.status || "Idle";
2610
+
2611
+ let hint = "Faust instruments are compiled locally in your browser the first time you use them.";
2612
+ if (selectedRenderer?.lastError) {
2613
+ hint = `Faust renderer error: ${selectedRenderer.lastError.message}`;
2614
+ } else if (selectedRenderer?.id === FAUST_CLAVIER_ID) {
2615
+ if (selectedRenderer.status === "Loading") {
2616
+ hint =
2617
+ "Compiling the built-in Faust Clavier in your browser. Once it is ready, these controls update the live renderer.";
2618
+ } else if (selectedRenderer.status === "Ready") {
2619
+ hint =
2620
+ "These controls are live on the built-in Faust Clavier. They shape attack, damping, release, body resonance, and stereo spread.";
2621
+ }
2622
+ } else if (selectedRenderer?.id === FAUST_CUSTOM_ID) {
2623
+ if (selectedRenderer.status === "Loading") {
2624
+ hint = "Compiling your Custom Faust source in the browser and rebuilding its parameter panel.";
2625
+ } else if (customFaustHasPendingChanges()) {
2626
+ hint =
2627
+ "Custom Faust keeps using the last compiled build until you compile the latest source in the editor.";
2628
+ } else if (selectedRenderer.status === "Ready") {
2629
+ hint =
2630
+ "The Custom Faust renderer is active. Any visible Faust controls from your DSP appear below automatically.";
2631
+ }
2632
+ }
2633
+ elements.faustRendererHint.textContent = hint;
2634
+
2635
+ for (const control of faustClavierControls) {
2636
+ const value = faustClavierRenderer.getControlValue(control.key);
2637
+ const inputValue = String(value);
2638
+ if (control.input.value !== inputValue) {
2639
+ control.input.value = inputValue;
2640
+ }
2641
+ control.input.disabled =
2642
+ !faustSelected ||
2643
+ selectedRenderer?.id !== FAUST_CLAVIER_ID ||
2644
+ selectedRenderer.status === "Loading";
2645
+ control.output.textContent = control.format(value);
2646
+ }
2647
+
2648
+ if (document.activeElement !== elements.faustCustomCodeInput) {
2649
+ elements.faustCustomCodeInput.value = state.customFaustSource;
2650
+ }
2651
+ elements.faustCustomCompileButton.disabled =
2652
+ !state.customFaustSource.trim() || customFaustRenderer.status === "Loading";
2653
+ elements.faustCustomResetButton.disabled = customFaustRenderer.status === "Loading";
2654
+ elements.faustCustomDirtyState.textContent = customFaustDirtyCopy();
2655
+ syncCustomFaustControls();
2656
+ }
2657
+
2658
+ async function createPlaybackSession(choiceValue = selectedPlaybackChoiceValue()) {
2659
+ const choice = resolvePlaybackChoice(choiceValue);
2660
+ if (!choice.renderer) {
2661
+ throw new Error("Selected playback renderer is unavailable.");
2662
+ }
2663
+
2664
+ const rendererState = (await choice.renderer.createPlaybackSession?.(choice.targetId)) || {};
2665
+ return {
2666
+ renderer: choice.renderer,
2667
+ rendererId: choice.renderer.id,
2668
+ targetId: choice.targetId,
2669
+ choiceValue: choice.value,
2670
+ timerIds: new Set(),
2671
+ cleanupTimerId: null,
2672
+ handoffAtMs: performance.now(),
2673
+ endsAtMs: performance.now(),
2674
+ ...rendererState,
2675
+ };
2676
  }
2677
 
2678
  function outputNoteKey(note, channel) {
 
2687
  }
2688
  }
2689
 
2690
+ function sendMidiPanicToOutput(output) {
2691
+ if (!output) {
2692
+ return false;
2693
+ }
2694
+
2695
+ for (let channel = 0; channel < 16; channel += 1) {
2696
+ sendOutputMessage(output, [0xb0 | channel, 64, 0]);
2697
+ sendOutputMessage(output, [0xb0 | channel, 121, 0]);
2698
+ for (let note = 0; note < 128; note += 1) {
2699
+ sendOutputMessage(output, [0x80 | (channel & 0x0f), note, 0]);
2700
+ }
2701
+ sendOutputMessage(output, [0xb0 | channel, 123, 0]);
2702
+ sendOutputMessage(output, [0xb0 | channel, 120, 0]);
2703
+ }
2704
+ return true;
2705
+ }
2706
+
2707
+ async function sendPlaybackPanic() {
2708
+ let sent = false;
2709
+ const activePlayback = state.activePlayback;
2710
+
2711
+ if (activePlayback?.renderer?.panicPlayback) {
2712
+ sent = (await activePlayback.renderer.panicPlayback(activePlayback)) || sent;
2713
+ }
2714
+
2715
+ for (const renderer of localPlaybackRenderers) {
2716
+ if (activePlayback?.renderer === renderer) {
2717
+ continue;
2718
+ }
2719
+ sent = (await renderer.panicTarget()) || sent;
2720
+ }
2721
+
2722
+ const selectedChoice = selectedPlaybackChoice();
2723
+ const matchesActiveSelection =
2724
+ activePlayback &&
2725
+ activePlayback.rendererId === selectedChoice.rendererId &&
2726
+ activePlayback.targetId === selectedChoice.targetId;
2727
+ if (!matchesActiveSelection && selectedChoice.renderer?.panicTarget) {
2728
+ sent = (await selectedChoice.renderer.panicTarget(selectedChoice.targetId)) || sent;
2729
+ }
2730
+
2731
+ return sent;
2732
+ }
2733
+
2734
  function stopActivePlayback() {
2735
  const playback = state.activePlayback;
2736
  if (!playback) {
 
2740
  playback.timerIds.forEach((timerId) => {
2741
  window.clearTimeout(timerId);
2742
  });
2743
+ playback.timerIds.clear();
2744
+ playback.cleanupTimerId = null;
2745
+ playback.renderer?.stopPlayback?.(playback);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2746
  state.activePlayback = null;
2747
  updateInfiniteActionState();
2748
  return true;
2749
  }
2750
 
2751
  function dispatchPlaybackEvent(playback, event) {
2752
+ playback.renderer?.dispatchEvent?.(playback, event);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2753
  }
2754
 
2755
  async function playPayload(
 
2766
  let playback = state.activePlayback;
2767
  if (!append || !playback) {
2768
  stopActivePlayback();
2769
+ playback = await createPlaybackSession();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2770
  state.activePlayback = playback;
2771
  }
2772
 
 
2778
 
2779
  const scheduleDelayMs = Math.max(0, startDelayMs);
2780
  const scheduleBaseMs = performance.now();
2781
+ const handoffMs = continuationHandoffMs(payload);
2782
  let cursorMs = 0;
2783
  for (const event of payload.events) {
2784
  cursorMs += event.delta_seconds * 1000;
 
2801
  }, scheduleDelayMs + cursorMs + 200);
2802
  playback.cleanupTimerId = cleanupTimerId;
2803
  playback.timerIds.add(cleanupTimerId);
2804
+ playback.handoffAtMs = Math.max(
2805
+ playback.handoffAtMs,
2806
+ scheduleBaseMs + scheduleDelayMs + handoffMs,
2807
+ );
2808
  playback.endsAtMs = Math.max(playback.endsAtMs, scheduleBaseMs + scheduleDelayMs + cursorMs);
2809
  updateInfiniteActionState();
2810
  }
 
2849
 
2850
  if (hadInfiniteState) {
2851
  stopInfiniteMode({ stopPlayback: true, silent: true });
2852
+ void sendPlaybackPanic();
2853
  setPhraseStatus(state.lastCapturedPhrase.length ? "Phrase ready" : "Waiting for MIDI");
2854
  setPhraseMessage(message || "Infinite mode stopped.");
2855
  return true;
2856
  }
2857
 
2858
  if (stopActivePlayback()) {
2859
+ void sendPlaybackPanic();
2860
  setPhraseStatus(state.lastCapturedPhrase.length ? "Phrase ready" : "Waiting for MIDI");
2861
  setPhraseMessage(message || "Playback stopped.");
2862
  return true;
 
2872
 
2873
  clearInfiniteScheduler();
2874
  const remainingPlaybackMs = state.activePlayback
2875
+ ? Math.max(0, state.activePlayback.handoffAtMs - performance.now())
2876
+ : continuationHandoffMs(prefixPayload);
2877
  const delayMs = Math.max(0, remainingPlaybackMs - infiniteLookaheadMs(prefixPayload));
2878
 
2879
  state.infiniteScheduleTimerId = window.setTimeout(() => {
 
2896
  state.infiniteAbortController = abortController;
2897
  updateInfiniteActionState();
2898
 
2899
+ const continueInfiniteFromMemory = async (reason) => {
2900
+ const { payload } = await requestMemoryGeneration({
2901
+ noteCount: preferredGenerationNoteCount(prefixEvents),
2902
+ statusLabel: state.activePlayback ? "Re-seeding from memory" : "Generating from memory",
2903
+ signal: abortController.signal,
2904
+ enforceEndConstraint: false,
2905
+ });
2906
+ if (!state.infiniteModeEnabled || runId !== state.infiniteRunId) {
2907
+ return true;
2908
+ }
2909
+
2910
+ const generatedPhrase = payload.generated_phrase;
2911
+ if (!generatedPhrase.event_count) {
2912
+ throw new Error("the memory fallback returned an empty phrase");
2913
+ }
2914
+
2915
+ applyGeneratedPhrasePayload(generatedPhrase);
2916
+ const startDelayMs = state.activePlayback
2917
+ ? Math.max(0, state.activePlayback.handoffAtMs - performance.now())
2918
+ : PLAYBACK_START_DELAY_MS;
2919
+ await playPayload(generatedPhrase, {
2920
+ startDelayMs,
2921
+ append: Boolean(state.activePlayback),
2922
+ });
2923
+ await refreshSessionActivity();
2924
+ await refreshSavedSessions();
2925
+ if (!state.infiniteModeEnabled || runId !== state.infiniteRunId) {
2926
+ return true;
2927
+ }
2928
+
2929
+ setPhraseStatus("Infinite");
2930
+ setPhraseMessage(
2931
+ payload.status_message ||
2932
+ `Infinite mode resumed from memory because ${reason}.`,
2933
+ );
2934
+ scheduleInfiniteStep(generatedPhrase, runId);
2935
+ return true;
2936
+ };
2937
+
2938
  try {
2939
  const { payload } = await requestContinuationFromEvents(prefixEvents, {
2940
  learnInput: false,
2941
  statusLabel: state.activePlayback ? "Queueing next" : "Sending",
2942
  signal: abortController.signal,
2943
+ enforceEndConstraint: false,
2944
  });
2945
  if (!state.infiniteModeEnabled || runId !== state.infiniteRunId) {
2946
  return;
2947
  }
2948
 
2949
+ const generatedPhrase = payload.generated_phrase;
2950
+ applyContinuationPayload({
2951
+ ...payload,
2952
+ generated_phrase: generatedPhrase,
2953
+ });
2954
+ if (!generatedPhrase.event_count) {
2955
+ await continueInfiniteFromMemory("the latest continuation was empty");
2956
  return;
2957
  }
2958
 
2959
  const startDelayMs = state.activePlayback
2960
+ ? Math.max(0, state.activePlayback.handoffAtMs - performance.now())
2961
  : PLAYBACK_START_DELAY_MS;
2962
+ await playPayload(generatedPhrase, {
2963
  startDelayMs,
2964
  append: Boolean(state.activePlayback),
2965
  });
 
2971
 
2972
  setPhraseStatus("Infinite");
2973
  setPhraseMessage(
2974
+ `Infinite mode running: ${generatedPhrase.note_count} notes queued from the latest continuation.`,
2975
  );
2976
+ scheduleInfiniteStep(generatedPhrase, runId);
2977
  } catch (error) {
2978
  if (error.name === "AbortError") {
2979
  return;
2980
  }
2981
+
2982
+ try {
2983
+ const resumed = await continueInfiniteFromMemory(
2984
+ "the latest continuation reached a dead end",
2985
+ );
2986
+ if (resumed) {
2987
+ return;
2988
+ }
2989
+ } catch (fallbackError) {
2990
+ error = fallbackError;
2991
+ }
2992
+
2993
  if (runId === state.infiniteRunId) {
2994
  stopInfiniteMode({
2995
  stopPlayback: false,
 
3023
  return;
3024
  }
3025
 
3026
+ const seedEvents = latestLoopSeedEvents();
 
 
3027
  if (!seedEvents.length) {
3028
  setPhraseMessage("Play or preview a phrase before starting infinite mode.", true);
3029
  return;
 
3044
  const { payload } = await requestContinuationFromEvents(seedEvents, {
3045
  learnInput: elements.learnInputToggle.checked,
3046
  signal: abortController.signal,
3047
+ enforceEndConstraint: false,
3048
  });
3049
  if (!state.infiniteModeEnabled || runId !== state.infiniteRunId) {
3050
  return;
3051
  }
3052
 
3053
+ const generatedPhrase = payload.generated_phrase;
3054
+ applyContinuationPayload({
3055
+ ...payload,
3056
+ generated_phrase: generatedPhrase,
3057
+ });
3058
+ if (!generatedPhrase.event_count) {
3059
  stopInfiniteMode({
3060
  stopPlayback: false,
3061
  message: "Infinite mode stopped because the first continuation was empty.",
 
3064
  return;
3065
  }
3066
 
3067
+ await playPayload(generatedPhrase);
3068
  await refreshSessionActivity();
3069
  await refreshSavedSessions();
3070
  if (!state.infiniteModeEnabled || runId !== state.infiniteRunId) {
 
3073
 
3074
  setPhraseStatus("Infinite");
3075
  setPhraseMessage(
3076
+ `Infinite mode running: ${generatedPhrase.note_count} notes in the first continuation.`,
3077
  );
3078
+ scheduleInfiniteStep(generatedPhrase, runId);
3079
  } catch (error) {
3080
  if (error.name === "AbortError") {
3081
  return;
 
3096
  }
3097
  }
3098
 
3099
+ function populatePlaybackChoices() {
3100
+ const outputs = state.midiAccess ? [...state.midiAccess.outputs.values()] : [];
3101
+ const previousChoiceValue = selectedPlaybackChoiceValue();
3102
+ const availableChoices = new Set([
3103
+ ...localPlaybackRenderers.map((renderer) => encodePlaybackChoice(renderer.id)),
3104
+ ...outputs.map((output) =>
3105
+ encodePlaybackChoice(WEB_MIDI_RENDERER_ID, output.id),
3106
+ ),
3107
+ ]);
3108
+
3109
+ const localRendererGroups = new Map();
3110
+ for (const renderer of localPlaybackRenderers) {
3111
+ const groupLabel = renderer.groupLabel || "Playback Renderers";
3112
+ const existing = localRendererGroups.get(groupLabel) || [];
3113
+ existing.push(
3114
+ `<option value="${encodePlaybackChoice(renderer.id)}">${renderer.getDisplayName()}</option>`,
3115
+ );
3116
+ localRendererGroups.set(groupLabel, existing);
3117
+ }
3118
+ const midiOptions = outputs
3119
+ .map(
3120
+ (output) =>
3121
+ `<option value="${encodePlaybackChoice(
3122
+ WEB_MIDI_RENDERER_ID,
3123
+ output.id,
3124
+ )}">${output.name || output.id}</option>`,
3125
+ )
3126
+ .join("");
3127
+
3128
+ elements.midiOutputSelect.disabled = false;
3129
+ elements.midiOutputSelect.innerHTML = [
3130
+ ...Array.from(localRendererGroups.entries()).map(
3131
+ ([groupLabel, options]) =>
3132
+ `<optgroup label="${groupLabel}">${options.join("")}</optgroup>`,
3133
+ ),
3134
+ midiOptions ? `<optgroup label="External MIDI">${midiOptions}</optgroup>` : "",
3135
+ ]
3136
+ .filter(Boolean)
3137
+ .join("");
3138
+ elements.midiOutputSelect.value = availableChoices.has(previousChoiceValue)
3139
+ ? previousChoiceValue
3140
+ : DEFAULT_PLAYBACK_CHOICE;
3141
+ updateSelectedOutput();
3142
+ }
3143
+
3144
  async function populateMidiSelectors() {
3145
+ populatePlaybackChoices();
3146
  if (!state.midiAccess) {
3147
  return;
3148
  }
3149
 
3150
  const inputs = [...state.midiAccess.inputs.values()];
 
3151
  const previousInputId = elements.midiInputSelect.value;
 
3152
 
3153
  elements.midiInputSelect.disabled = false;
3154
  elements.midiInputSelect.innerHTML = inputs.length
 
3160
  .join("")
3161
  : `<option value="">No MIDI inputs found</option>`;
3162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3163
  if (inputs.length) {
3164
  const inputId = state.midiAccess.inputs.has(previousInputId)
3165
  ? previousInputId
 
3249
  }
3250
 
3251
  function updateSelectedOutput() {
3252
+ setSelectedOutputName(playbackChoiceLabel());
3253
+ syncFaustRendererPanel();
3254
+ }
3255
+
3256
+ async function compileCustomFaustFromEditor() {
3257
+ const source = elements.faustCustomCodeInput.value;
3258
+ if (!source.trim()) {
3259
+ throw new Error("Paste or write a Faust DSP before compiling.");
3260
  }
3261
 
3262
+ setCustomFaustSource(source);
3263
+ stopLoopAndPlayback("Stopped playback before recompiling Custom Faust.");
3264
+ await customFaustRenderer.prepare({ forceRecompile: true });
3265
+ refreshCustomFaustControlState();
3266
  }
3267
 
3268
  function clearPhrases() {
 
3438
  }
3439
  });
3440
 
3441
+ elements.generateMemoryButton.addEventListener("click", async () => {
3442
+ try {
3443
+ await generateFreshPhrase();
3444
+ } catch (error) {
3445
+ setPhraseMessage(error.message, true);
3446
+ setPhraseStatus("Error");
3447
+ }
3448
+ });
3449
+
3450
  elements.replayGeneratedButton.addEventListener("click", async () => {
3451
  if (!state.lastGeneratedPhrase) {
3452
  setPhraseMessage("No generated phrase is available yet.", true);
 
3497
 
3498
  elements.midiOutputSelect.addEventListener("change", async () => {
3499
  updateSelectedOutput();
3500
+ const choice = selectedPlaybackChoice();
3501
+ try {
3502
+ if (choice.rendererId === WEB_MIDI_RENDERER_ID) {
3503
+ const output = midiOutputById(choice.targetId);
3504
+ if (!output) {
3505
+ setPhraseMessage("The selected MIDI output is unavailable.", true);
3506
+ return;
3507
+ }
3508
  await output.open();
3509
+ } else if (choice.renderer instanceof FaustPolyRenderer) {
3510
+ await choice.renderer.prepare();
3511
+ if (choice.rendererId === FAUST_CUSTOM_ID) {
3512
+ refreshCustomFaustControlState();
3513
+ }
3514
  }
3515
+ setPhraseMessage(`Playback renderer set to ${elements.selectedOutputName.textContent}.`);
3516
+ } catch (error) {
3517
+ setPhraseMessage(error.message, true);
3518
+ } finally {
3519
+ syncFaustRendererPanel();
3520
+ }
3521
+ });
3522
+
3523
+ faustClavierControls.forEach((control) => {
3524
+ control.input.addEventListener("input", () => {
3525
+ faustClavierRenderer.setControlValue(control.key, control.input.value);
3526
+ });
3527
+ });
3528
+
3529
+ elements.faustCustomCodeInput.addEventListener("input", () => {
3530
+ setCustomFaustSource(elements.faustCustomCodeInput.value);
3531
+ syncFaustRendererPanel();
3532
+ });
3533
+
3534
+ elements.faustCustomCompileButton.addEventListener("click", async () => {
3535
+ try {
3536
+ await compileCustomFaustFromEditor();
3537
+ setPhraseMessage("Custom Faust compiled and is ready for playback.");
3538
+ } catch (error) {
3539
+ setPhraseMessage(error.message, true);
3540
+ setPhraseStatus("Error");
3541
+ }
3542
+ });
3543
+
3544
+ elements.faustCustomResetButton.addEventListener("click", async () => {
3545
+ try {
3546
+ const starter = await loadCustomFaustTemplate();
3547
+ setCustomFaustSource(starter);
3548
+ syncFaustRendererPanel();
3549
+ setPhraseMessage("Restored the Custom Faust starter template. Compile to use it.");
3550
+ } catch (error) {
3551
+ setPhraseMessage(error.message, true);
3552
  }
 
3553
  });
3554
 
3555
  elements.learnInputToggle.addEventListener("change", () => {
 
3560
  renderSessionSettingsSummary();
3561
  });
3562
 
3563
+ elements.markovOrderInput.addEventListener("change", () => {
3564
+ elements.markovOrderInput.value = String(
3565
+ normalizedMarkovOrder(elements.markovOrderInput.value),
3566
+ );
3567
+ renderSessionSettingsSummary();
3568
+ });
3569
+
3570
  elements.forgetToggle.addEventListener("change", () => {
3571
  updateKeepLastFieldState();
3572
  renderSessionSettingsSummary();
 
3591
  noteCount == null ? "" : String(noteCount);
3592
  });
3593
 
3594
+ elements.importMidiFilesButton.addEventListener("click", () => {
3595
+ elements.midiImportInput.click();
3596
+ });
3597
+
3598
+ elements.importMidiFolderButton.addEventListener("click", () => {
3599
+ elements.midiFolderImportInput.click();
3600
+ });
3601
+
3602
+ elements.midiImportInput.addEventListener("change", async (event) => {
3603
+ const input = event.target;
3604
+ try {
3605
+ await importSelectedMidiFiles(input.files, "file selection");
3606
+ } catch (error) {
3607
+ setPhraseMessage(error.message, true);
3608
+ } finally {
3609
+ input.value = "";
3610
+ }
3611
+ });
3612
+
3613
+ elements.midiFolderImportInput.addEventListener("change", async (event) => {
3614
+ const input = event.target;
3615
+ try {
3616
+ await importSelectedMidiFiles(input.files, "folder");
3617
+ } catch (error) {
3618
+ setPhraseMessage(error.message, true);
3619
+ } finally {
3620
+ input.value = "";
3621
+ }
3622
+ });
3623
+
3624
  window.addEventListener("resize", () => {
3625
  drawPianoRoll(
3626
  elements.inputRoll,
 
3634
 
3635
  async function initialize() {
3636
  bindEvents();
3637
+ try {
3638
+ await initializeCustomFaustSource();
3639
+ } catch (error) {
3640
+ setPhraseMessage(error.message, true);
3641
+ }
3642
+ populatePlaybackChoices();
3643
  syncAuthUI();
3644
  renderSavedSessions([]);
3645
  clearCurrentSessionState();
3646
  setControlView("perform");
3647
  setActivityView("history");
3648
  setSelectedInputName("No MIDI input selected");
3649
+ updateSelectedOutput();
3650
+ syncFaustRendererPanel();
3651
  setLastMidiEvent("None yet");
3652
  updateKeepLastFieldState();
3653
  renderSessionSettingsSummary();
frontend/faust/continuator-clavier.dsp ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import("stdfaust.lib");
2
+
3
+ declare name "Continuator Clavier";
4
+ declare options "[midi:on][nvoices:24]";
5
+
6
+ freq = hslider("freq[hidden:1]", 440.0, 20.0, 20000.0, 0.01);
7
+ gain = hslider("gain[hidden:1]", 0.3, 0.0, 1.0, 0.001);
8
+ gate = button("gate[hidden:1]");
9
+
10
+ brightness =
11
+ hslider("Continuator Clavier/[0]brightness[style:knob]", 0.58, 0.0, 1.0, 0.01)
12
+ : si.smoo;
13
+ hardness =
14
+ hslider("Continuator Clavier/[1]hardness[style:knob]", 0.34, 0.0, 1.0, 0.01)
15
+ : si.smoo;
16
+ damping =
17
+ hslider("Continuator Clavier/[2]damping[style:knob]", 0.42, 0.0, 1.0, 0.01)
18
+ : si.smoo;
19
+ release =
20
+ hslider(
21
+ "Continuator Clavier/[3]release[unit:s][style:knob]",
22
+ 0.95,
23
+ 0.08,
24
+ 4.0,
25
+ 0.01
26
+ )
27
+ : si.smoo;
28
+ body =
29
+ hslider("Continuator Clavier/[4]body[style:knob]", 0.33, 0.0, 1.0, 0.01)
30
+ : si.smoo;
31
+ stereo =
32
+ hslider("Continuator Clavier/[5]stereo[style:knob]", 0.45, 0.0, 1.0, 0.01)
33
+ : si.smoo;
34
+
35
+ strike = en.adsr(
36
+ 0.0015 + (1.0 - hardness) * 0.008,
37
+ 0.08 + damping * 0.14,
38
+ 0.0,
39
+ 0.10 + release * 0.22,
40
+ gate
41
+ );
42
+ tail = en.adsr(
43
+ 0.002,
44
+ 0.18 + damping * 0.18,
45
+ 0.0,
46
+ release,
47
+ gate
48
+ );
49
+
50
+ velocity = max(0.03, pow(gain, 0.72 - hardness * 0.28));
51
+ cutoff = 900.0 + brightness * 7800.0;
52
+ overtone = 2.0 + brightness * 2.6;
53
+ pan = stereo * 0.32 * sin(freq * 0.0017);
54
+
55
+ excitation =
56
+ os.osc(freq) * 0.17 +
57
+ os.sawtooth(freq * overtone) * (0.012 + brightness * 0.07) +
58
+ os.osc(freq * (3.0 + brightness * 4.0)) * (0.01 + hardness * 0.05) +
59
+ no.noise * (0.001 + hardness * 0.016);
60
+
61
+ bodyResonance =
62
+ os.osc(freq * 0.5) * (0.018 + body * 0.075) +
63
+ os.osc(freq * 1.5) * (0.006 + body * 0.022);
64
+
65
+ voice = (
66
+ ((excitation * strike) : fi.lowpass(3, cutoff)) +
67
+ (bodyResonance * tail)
68
+ ) * velocity;
69
+
70
+ left = voice * sqrt(0.5 * (1.0 - pan));
71
+ right = voice * sqrt(0.5 * (1.0 + pan));
72
+
73
+ process = left, right;
frontend/faust/custom-poly-template.dsp ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import("stdfaust.lib");
2
+
3
+ declare name "Continuator Custom Starter";
4
+ declare options "[midi:on][nvoices:24]";
5
+
6
+ freq = hslider("freq[hidden:1]", 440.0, 20.0, 20000.0, 0.01);
7
+ gain = hslider("gain[hidden:1]", 0.3, 0.0, 1.0, 0.001);
8
+ gate = button("gate[hidden:1]");
9
+
10
+ tone =
11
+ hslider("Custom Starter/[0]tone[style:knob]", 0.62, 0.0, 1.0, 0.01)
12
+ : si.smoo;
13
+ release =
14
+ hslider(
15
+ "Custom Starter/[1]release[unit:s][style:knob]",
16
+ 0.90,
17
+ 0.08,
18
+ 4.0,
19
+ 0.01
20
+ )
21
+ : si.smoo;
22
+ detune =
23
+ hslider("Custom Starter/[2]detune[style:knob]", 0.16, 0.0, 1.0, 0.01)
24
+ : si.smoo;
25
+ air =
26
+ hslider("Custom Starter/[3]air[style:knob]", 0.22, 0.0, 1.0, 0.01)
27
+ : si.smoo;
28
+
29
+ env = en.adsr(0.004, 0.12, 0.0, release, gate);
30
+ velocity = max(0.04, pow(gain, 0.74));
31
+ cutoff = 700.0 + tone * 7600.0;
32
+
33
+ body =
34
+ os.sawtooth(freq) * 0.12 +
35
+ os.osc(freq * 2.0) * (0.03 + tone * 0.06) +
36
+ os.osc(freq * (1.0 + detune * 0.02)) * (0.02 + detune * 0.08) +
37
+ no.noise * (0.001 + air * 0.012);
38
+
39
+ voice = ((body : fi.lowpass(3, cutoff)) * env) * velocity;
40
+
41
+ process = voice, voice;
frontend/index.html CHANGED
@@ -321,8 +321,8 @@
321
  <div>
322
  <h3>MIDI I/O</h3>
323
  <p class="section-copy">
324
- Connect browser MIDI, choose ports, and capture phrases
325
- from your keyboard.
326
  </p>
327
  </div>
328
  </div>
@@ -341,9 +341,9 @@
341
  </select>
342
  </div>
343
  <div class="field">
344
- <label for="midi-output-select">Playback Output</label>
345
  <select id="midi-output-select" disabled>
346
- <option>Browser Synth</option>
347
  </select>
348
  </div>
349
  <label class="toggle">
@@ -356,8 +356,8 @@
356
  <dd id="selected-input-name">None</dd>
357
  </div>
358
  <div>
359
- <dt>Selected Output</dt>
360
- <dd id="selected-output-name">Browser Synth</dd>
361
  </div>
362
  <div>
363
  <dt>Last MIDI Event</dt>
@@ -370,6 +370,163 @@
370
  </dl>
371
  </section>
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  <section class="panel-section">
374
  <div class="section-head">
375
  <div>
@@ -382,6 +539,9 @@
382
  </div>
383
  <div class="button-row">
384
  <button id="send-phrase-button">Send Phrase</button>
 
 
 
385
  <button id="replay-generated-button" class="ghost">Replay Output</button>
386
  <button id="clear-phrase-button" class="ghost">Clear</button>
387
  </div>
@@ -486,6 +646,21 @@
486
  <input id="transpose-toggle" type="checkbox" />
487
  <span>Transpose incoming phrases before learning</span>
488
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  <div class="field">
490
  <label for="decay-mode-select">Decay mode</label>
491
  <select id="decay-mode-select">
@@ -536,6 +711,55 @@
536
  </div>
537
  </dl>
538
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  </div>
540
  </section>
541
 
@@ -640,6 +864,6 @@
640
  </section>
641
  </main>
642
 
643
- <script type="module" src="/assets/app.js?v=20260423-readme-panel"></script>
644
  </body>
645
  </html>
 
321
  <div>
322
  <h3>MIDI I/O</h3>
323
  <p class="section-copy">
324
+ Connect browser MIDI, choose an input, and route playback
325
+ to browser renderers or external synths.
326
  </p>
327
  </div>
328
  </div>
 
341
  </select>
342
  </div>
343
  <div class="field">
344
+ <label for="midi-output-select">Playback Renderer</label>
345
  <select id="midi-output-select" disabled>
346
+ <option>Browser Triangle</option>
347
  </select>
348
  </div>
349
  <label class="toggle">
 
356
  <dd id="selected-input-name">None</dd>
357
  </div>
358
  <div>
359
+ <dt>Selected Renderer</dt>
360
+ <dd id="selected-output-name">Browser Triangle</dd>
361
  </div>
362
  <div>
363
  <dt>Last MIDI Event</dt>
 
370
  </dl>
371
  </section>
372
 
373
+ <section id="faust-renderer-panel" class="panel-section accent-section" hidden>
374
+ <div class="section-head">
375
+ <div>
376
+ <h3>Faust Renderer</h3>
377
+ <p class="section-copy">
378
+ Route playback through the built-in Clavier or compile
379
+ your own polyphonic Faust instrument directly in the
380
+ browser.
381
+ </p>
382
+ </div>
383
+ <div id="faust-renderer-status" class="status-pill">Idle</div>
384
+ </div>
385
+ <div id="faust-clavier-panel">
386
+ <div class="faust-control-grid">
387
+ <div class="field faust-field">
388
+ <div class="faust-label-row">
389
+ <label for="faust-brightness-input">Brightness</label>
390
+ <output id="faust-brightness-value" for="faust-brightness-input">0.58</output>
391
+ </div>
392
+ <input
393
+ id="faust-brightness-input"
394
+ type="range"
395
+ min="0"
396
+ max="1"
397
+ step="0.01"
398
+ value="0.58"
399
+ />
400
+ </div>
401
+ <div class="field faust-field">
402
+ <div class="faust-label-row">
403
+ <label for="faust-hardness-input">Hardness</label>
404
+ <output id="faust-hardness-value" for="faust-hardness-input">0.34</output>
405
+ </div>
406
+ <input
407
+ id="faust-hardness-input"
408
+ type="range"
409
+ min="0"
410
+ max="1"
411
+ step="0.01"
412
+ value="0.34"
413
+ />
414
+ </div>
415
+ <div class="field faust-field">
416
+ <div class="faust-label-row">
417
+ <label for="faust-damping-input">Damping</label>
418
+ <output id="faust-damping-value" for="faust-damping-input">0.42</output>
419
+ </div>
420
+ <input
421
+ id="faust-damping-input"
422
+ type="range"
423
+ min="0"
424
+ max="1"
425
+ step="0.01"
426
+ value="0.42"
427
+ />
428
+ </div>
429
+ <div class="field faust-field">
430
+ <div class="faust-label-row">
431
+ <label for="faust-release-input">Release</label>
432
+ <output id="faust-release-value" for="faust-release-input">0.95 s</output>
433
+ </div>
434
+ <input
435
+ id="faust-release-input"
436
+ type="range"
437
+ min="0.08"
438
+ max="4"
439
+ step="0.01"
440
+ value="0.95"
441
+ />
442
+ </div>
443
+ <div class="field faust-field">
444
+ <div class="faust-label-row">
445
+ <label for="faust-body-input">Body</label>
446
+ <output id="faust-body-value" for="faust-body-input">0.33</output>
447
+ </div>
448
+ <input
449
+ id="faust-body-input"
450
+ type="range"
451
+ min="0"
452
+ max="1"
453
+ step="0.01"
454
+ value="0.33"
455
+ />
456
+ </div>
457
+ <div class="field faust-field">
458
+ <div class="faust-label-row">
459
+ <label for="faust-stereo-input">Stereo</label>
460
+ <output id="faust-stereo-value" for="faust-stereo-input">0.45</output>
461
+ </div>
462
+ <input
463
+ id="faust-stereo-input"
464
+ type="range"
465
+ min="0"
466
+ max="1"
467
+ step="0.01"
468
+ value="0.45"
469
+ />
470
+ </div>
471
+ </div>
472
+ </div>
473
+ <div id="faust-custom-panel" hidden>
474
+ <div class="section-head faust-section-head">
475
+ <div>
476
+ <h4>Custom Faust</h4>
477
+ <p class="section-copy">
478
+ Paste a polyphonic Faust DSP with hidden
479
+ <code>freq</code>, <code>gain</code>, and
480
+ <code>gate</code> controls, then compile it locally.
481
+ </p>
482
+ </div>
483
+ <div class="button-row faust-editor-actions">
484
+ <button id="faust-custom-compile-button" type="button">
485
+ Compile &amp; Use
486
+ </button>
487
+ <button
488
+ id="faust-custom-reset-button"
489
+ class="ghost"
490
+ type="button"
491
+ >
492
+ Restore Starter
493
+ </button>
494
+ </div>
495
+ </div>
496
+ <div class="field faust-editor-field">
497
+ <label for="faust-custom-code-input">DSP Source</label>
498
+ <textarea
499
+ id="faust-custom-code-input"
500
+ class="faust-code-input"
501
+ spellcheck="false"
502
+ autocapitalize="off"
503
+ autocomplete="off"
504
+ ></textarea>
505
+ <p id="faust-custom-dirty-state" class="field-hint">
506
+ Edits stay local to this browser. Compile to turn the
507
+ latest source into the active renderer.
508
+ </p>
509
+ </div>
510
+ <div class="section-head faust-section-head">
511
+ <div>
512
+ <h4>Custom Parameters</h4>
513
+ <p class="section-copy">
514
+ Visible Faust controls appear here automatically after a
515
+ successful compile.
516
+ </p>
517
+ </div>
518
+ </div>
519
+ <div id="faust-custom-controls" class="faust-control-grid"></div>
520
+ <p id="faust-custom-controls-hint" class="field-hint">
521
+ Compile a polyphonic instrument to expose its parameters.
522
+ </p>
523
+ </div>
524
+ <p id="faust-renderer-hint" class="field-hint">
525
+ Faust instruments are compiled locally in your browser the
526
+ first time you use them.
527
+ </p>
528
+ </section>
529
+
530
  <section class="panel-section">
531
  <div class="section-head">
532
  <div>
 
539
  </div>
540
  <div class="button-row">
541
  <button id="send-phrase-button">Send Phrase</button>
542
+ <button id="generate-memory-button" class="ghost">
543
+ Generate From Memory
544
+ </button>
545
  <button id="replay-generated-button" class="ghost">Replay Output</button>
546
  <button id="clear-phrase-button" class="ghost">Clear</button>
547
  </div>
 
646
  <input id="transpose-toggle" type="checkbox" />
647
  <span>Transpose incoming phrases before learning</span>
648
  </label>
649
+ <div class="field">
650
+ <label for="markov-order-input">Markov order K</label>
651
+ <input
652
+ id="markov-order-input"
653
+ type="number"
654
+ min="1"
655
+ max="16"
656
+ step="1"
657
+ value="4"
658
+ />
659
+ <p class="field-hint">
660
+ Higher K follows longer contexts, but can hit dead ends
661
+ sooner.
662
+ </p>
663
+ </div>
664
  <div class="field">
665
  <label for="decay-mode-select">Decay mode</label>
666
  <select id="decay-mode-select">
 
711
  </div>
712
  </dl>
713
  </section>
714
+
715
+ <section class="panel-section accent-section">
716
+ <div class="section-head">
717
+ <div>
718
+ <h3>MIDI Import</h3>
719
+ <p class="section-copy">
720
+ Load existing MIDI files into the current Continuator
721
+ session and use them as live memory.
722
+ </p>
723
+ </div>
724
+ </div>
725
+ <div class="button-row">
726
+ <button
727
+ id="import-midi-files-button"
728
+ class="ghost"
729
+ type="button"
730
+ >
731
+ Import MIDI Files
732
+ </button>
733
+ <button
734
+ id="import-midi-folder-button"
735
+ class="ghost"
736
+ type="button"
737
+ >
738
+ Import MIDI Folder
739
+ </button>
740
+ </div>
741
+ <p class="field-hint">
742
+ Imported phrases are learned into the active session memory
743
+ and logged like other learned inputs. Folder import works
744
+ best in Chromium-based browsers.
745
+ </p>
746
+ <input
747
+ id="midi-import-input"
748
+ type="file"
749
+ accept=".mid,.midi,audio/midi"
750
+ multiple
751
+ hidden
752
+ />
753
+ <input
754
+ id="midi-folder-import-input"
755
+ type="file"
756
+ accept=".mid,.midi,audio/midi"
757
+ multiple
758
+ webkitdirectory
759
+ directory
760
+ hidden
761
+ />
762
+ </section>
763
  </div>
764
  </section>
765
 
 
864
  </section>
865
  </main>
866
 
867
+ <script type="module" src="/assets/app.js?v=20260423-faust-custom"></script>
868
  </body>
869
  </html>
frontend/styles.css CHANGED
@@ -621,6 +621,11 @@ input[type="number"] {
621
  padding: 12px 14px;
622
  }
623
 
 
 
 
 
 
624
  input[type="number"]:disabled {
625
  opacity: 0.55;
626
  }
@@ -740,6 +745,68 @@ input[type="password"]:disabled {
740
  line-height: 1.45;
741
  }
742
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
  .info-grid {
744
  display: grid;
745
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1034,6 +1101,10 @@ input[type="password"]:disabled {
1034
  grid-template-columns: 1fr;
1035
  }
1036
 
 
 
 
 
1037
  .piano-roll {
1038
  height: 206px;
1039
  }
 
621
  padding: 12px 14px;
622
  }
623
 
624
+ input[type="range"] {
625
+ width: 100%;
626
+ accent-color: var(--accent);
627
+ }
628
+
629
  input[type="number"]:disabled {
630
  opacity: 0.55;
631
  }
 
745
  line-height: 1.45;
746
  }
747
 
748
+ .faust-control-grid {
749
+ display: grid;
750
+ grid-template-columns: repeat(2, minmax(0, 1fr));
751
+ gap: 14px;
752
+ margin-bottom: 12px;
753
+ }
754
+
755
+ .faust-field {
756
+ margin-bottom: 0;
757
+ padding: 14px 16px;
758
+ border-radius: 18px;
759
+ background: rgba(255, 255, 255, 0.04);
760
+ border: 1px solid rgba(255, 255, 255, 0.05);
761
+ }
762
+
763
+ .faust-label-row {
764
+ display: flex;
765
+ align-items: baseline;
766
+ justify-content: space-between;
767
+ gap: 12px;
768
+ }
769
+
770
+ .faust-label-row output {
771
+ color: var(--secondary);
772
+ font-size: 0.82rem;
773
+ font-variant-numeric: tabular-nums;
774
+ }
775
+
776
+ .faust-section-head {
777
+ margin-top: 18px;
778
+ }
779
+
780
+ .faust-editor-actions {
781
+ margin-bottom: 0;
782
+ flex-wrap: wrap;
783
+ }
784
+
785
+ .faust-editor-field {
786
+ margin-bottom: 16px;
787
+ }
788
+
789
+ .faust-code-input {
790
+ width: 100%;
791
+ min-height: 260px;
792
+ padding: 16px;
793
+ border-radius: 18px;
794
+ border: 1px solid rgba(255, 255, 255, 0.08);
795
+ background: rgba(7, 14, 20, 0.72);
796
+ color: var(--text);
797
+ font: 0.88rem/1.6 "SFMono-Regular", "Fira Code", "IBM Plex Mono", monospace;
798
+ resize: vertical;
799
+ }
800
+
801
+ .faust-code-input:focus {
802
+ outline: 1px solid rgba(109, 211, 206, 0.4);
803
+ border-color: rgba(109, 211, 206, 0.42);
804
+ }
805
+
806
+ .faust-code-input::selection {
807
+ background: rgba(244, 162, 97, 0.28);
808
+ }
809
+
810
  .info-grid {
811
  display: grid;
812
  grid-template-columns: repeat(2, minmax(0, 1fr));
 
1101
  grid-template-columns: 1fr;
1102
  }
1103
 
1104
+ .faust-control-grid {
1105
+ grid-template-columns: 1fr;
1106
+ }
1107
+
1108
  .piano-roll {
1109
  height: 206px;
1110
  }
frontend/vendor/faustwasm/esm/index.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/vendor/faustwasm/libfaust-wasm/libfaust-wasm.data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b8f904edbf2c2aa3297e5c5896ec0714ec1599f292cf508b46b24c5068976539
3
+ size 2208439
frontend/vendor/faustwasm/libfaust-wasm/libfaust-wasm.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/vendor/faustwasm/libfaust-wasm/libfaust-wasm.wasm ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5322717e99bb1d3888c59297faf77a2c43bef85cf8081da57d1ebe0652312285
3
+ size 3130696
package-lock.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "continuator_front",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "dependencies": {
8
+ "@grame/faustwasm": "^0.16.1"
9
+ }
10
+ },
11
+ "node_modules/@grame/faustwasm": {
12
+ "version": "0.16.1",
13
+ "resolved": "https://registry.npmjs.org/@grame/faustwasm/-/faustwasm-0.16.1.tgz",
14
+ "integrity": "sha512-bc3AbJm4bPyS8ilvTnsbxjrFYqUO73uN5oatfMlJPsSvdzx/50q5ViMilmGdNV/Xrf7pbNdwu8Wka5ZwOraxwA==",
15
+ "license": "LGPL-3.0",
16
+ "dependencies": {
17
+ "@types/emscripten": "^1.39.10"
18
+ },
19
+ "bin": {
20
+ "faust2sndfile-ts": "scripts/faust2sndfile.js",
21
+ "faust2svg-ts": "scripts/faust2svg.js",
22
+ "faust2wasm-ts": "scripts/faust2wasm.js"
23
+ }
24
+ },
25
+ "node_modules/@types/emscripten": {
26
+ "version": "1.41.5",
27
+ "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
28
+ "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
29
+ "license": "MIT"
30
+ }
31
+ }
32
+ }
package.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "dependencies": {
3
+ "@grame/faustwasm": "^0.16.1"
4
+ }
5
+ }