Spaces:
Running
Running
Add MIDI import and Faust playback renderers
Browse files- .gitattributes +2 -0
- backend/app/continuator_adapter.py +210 -26
- backend/app/main.py +57 -2
- backend/app/schemas.py +34 -0
- backend/app/session_manager.py +87 -1
- backend/requirements.txt +1 -1
- backend/vendor/ctor/continuator.py +24 -21
- backend/vendor/midi_stuff/mini_muse.py +22 -19
- frontend/app.js +1647 -215
- frontend/faust/continuator-clavier.dsp +73 -0
- frontend/faust/custom-poly-template.dsp +41 -0
- frontend/index.html +231 -7
- frontend/styles.css +71 -0
- frontend/vendor/faustwasm/esm/index.js +0 -0
- frontend/vendor/faustwasm/libfaust-wasm/libfaust-wasm.data +3 -0
- frontend/vendor/faustwasm/libfaust-wasm/libfaust-wasm.js +0 -0
- frontend/vendor/faustwasm/libfaust-wasm/libfaust-wasm.wasm +3 -0
- package-lock.json +32 -0
- package.json +5 -0
.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 =
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 |
-
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
raise NoContinuationAvailable(
|
| 232 |
-
"The
|
| 233 |
)
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 120 |
this.master.connect(this.context.destination);
|
| 121 |
}
|
| 122 |
if (this.context.state === "suspended") {
|
|
@@ -124,12 +270,17 @@ class BrowserSynth {
|
|
| 124 |
}
|
| 125 |
}
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
|
|
|
| 129 |
}
|
| 130 |
|
| 131 |
-
|
| 132 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
| 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.
|
| 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 |
-
|
| 183 |
-
if (
|
|
|
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 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 |
-
|
| 799 |
renderGeneratedStats(payload);
|
| 800 |
} else {
|
| 801 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1365 |
-
|
| 1366 |
-
|
| 1367 |
-
state.lastGeneratedPhrase = payload.generated_phrase;
|
| 1368 |
-
renderGeneratedStats(payload.generated_phrase);
|
| 1369 |
-
updateInfiniteActionState();
|
| 1370 |
-
}
|
| 1371 |
|
| 1372 |
-
|
| 1373 |
-
|
| 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 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1394 |
|
| 1395 |
-
|
| 1396 |
-
|
| 1397 |
-
|
| 1398 |
-
|
| 1399 |
-
{
|
| 1400 |
-
);
|
| 1401 |
-
|
| 1402 |
-
|
| 1403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1404 |
}
|
| 1405 |
-
|
| 1406 |
-
|
| 1407 |
-
|
| 1408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1409 |
}
|
| 1410 |
|
| 1411 |
-
|
| 1412 |
-
|
| 1413 |
-
elements.serverStatus.textContent = payload.ok
|
| 1414 |
-
? payload.seeded
|
| 1415 |
-
? "Healthy / seeded"
|
| 1416 |
-
: "Healthy / empty memory"
|
| 1417 |
-
: "Unavailable";
|
| 1418 |
}
|
| 1419 |
|
| 1420 |
-
function
|
| 1421 |
-
if (!state.
|
| 1422 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1423 |
}
|
| 1424 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1450 |
-
|
| 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 |
-
|
| 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.
|
| 1628 |
-
:
|
| 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 |
-
|
| 1662 |
-
|
| 1663 |
-
|
| 1664 |
-
|
| 1665 |
-
|
| 1666 |
-
|
| 1667 |
-
|
| 1668 |
return;
|
| 1669 |
}
|
| 1670 |
|
| 1671 |
const startDelayMs = state.activePlayback
|
| 1672 |
-
? Math.max(0, state.activePlayback.
|
| 1673 |
: PLAYBACK_START_DELAY_MS;
|
| 1674 |
-
await playPayload(
|
| 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: ${
|
| 1687 |
);
|
| 1688 |
-
scheduleInfiniteStep(
|
| 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 =
|
| 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 |
-
|
| 1755 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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: ${
|
| 1774 |
);
|
| 1775 |
-
scheduleInfiniteStep(
|
| 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 |
-
|
| 1923 |
-
|
| 1924 |
-
|
| 1925 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1926 |
}
|
| 1927 |
|
| 1928 |
-
|
| 1929 |
-
|
|
|
|
|
|
|
| 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
|
| 2156 |
-
|
| 2157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2158 |
await output.open();
|
| 2159 |
-
}
|
| 2160 |
-
|
| 2161 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 325 |
-
|
| 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
|
| 345 |
<select id="midi-output-select" disabled>
|
| 346 |
-
<option>Browser
|
| 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
|
| 360 |
-
<dd id="selected-output-name">Browser
|
| 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-
|
| 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 & 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 |
+
}
|