Spaces:
Running
Running
Persist session MIDI preferences
Browse files- backend/app/main.py +22 -0
- backend/app/schemas.py +17 -0
- backend/app/session_manager.py +38 -0
- frontend/app.js +157 -24
- frontend/index.html +1 -5
backend/app/main.py
CHANGED
|
@@ -33,6 +33,8 @@ from .schemas import (
|
|
| 33 |
SessionMemoryResponse,
|
| 34 |
UpdateSessionNameRequest,
|
| 35 |
UpdateSessionNameResponse,
|
|
|
|
|
|
|
| 36 |
UpdateSessionSettingsRequest,
|
| 37 |
UpdateSessionSettingsResponse,
|
| 38 |
UserSessionsResponse,
|
|
@@ -221,6 +223,26 @@ def update_session_name(
|
|
| 221 |
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
|
| 222 |
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
@app.patch(
|
| 225 |
"/api/sessions/{session_id}/settings",
|
| 226 |
response_model=UpdateSessionSettingsResponse,
|
|
|
|
| 33 |
SessionMemoryResponse,
|
| 34 |
UpdateSessionNameRequest,
|
| 35 |
UpdateSessionNameResponse,
|
| 36 |
+
UpdateSessionPreferencesRequest,
|
| 37 |
+
UpdateSessionPreferencesResponse,
|
| 38 |
UpdateSessionSettingsRequest,
|
| 39 |
UpdateSessionSettingsResponse,
|
| 40 |
UserSessionsResponse,
|
|
|
|
| 223 |
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
|
| 224 |
|
| 225 |
|
| 226 |
+
@app.patch(
|
| 227 |
+
"/api/sessions/{session_id}/preferences",
|
| 228 |
+
response_model=UpdateSessionPreferencesResponse,
|
| 229 |
+
tags=["session"],
|
| 230 |
+
)
|
| 231 |
+
def update_session_preferences(
|
| 232 |
+
session_id: str,
|
| 233 |
+
payload: UpdateSessionPreferencesRequest,
|
| 234 |
+
current_user: AuthenticatedUser | None = Depends(get_optional_current_user),
|
| 235 |
+
) -> UpdateSessionPreferencesResponse:
|
| 236 |
+
try:
|
| 237 |
+
return session_manager.update_session_preferences(
|
| 238 |
+
session_id,
|
| 239 |
+
None if current_user is None else current_user.id,
|
| 240 |
+
payload,
|
| 241 |
+
)
|
| 242 |
+
except UnknownSessionError as error:
|
| 243 |
+
raise HTTPException(status_code=404, detail=f"Unknown session: {error.args[0]}") from error
|
| 244 |
+
|
| 245 |
+
|
| 246 |
@app.patch(
|
| 247 |
"/api/sessions/{session_id}/settings",
|
| 248 |
response_model=UpdateSessionSettingsResponse,
|
backend/app/schemas.py
CHANGED
|
@@ -80,6 +80,10 @@ class SessionConfiguration(BaseModel):
|
|
| 80 |
markov_order: int = Field(default=4, ge=1, le=16)
|
| 81 |
seeded: bool
|
| 82 |
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
class CreateSessionResponse(BaseModel):
|
|
@@ -206,6 +210,19 @@ class UpdateSessionNameResponse(BaseModel):
|
|
| 206 |
configuration: SessionConfiguration
|
| 207 |
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
class PublicConfigResponse(BaseModel):
|
| 210 |
app_name: str
|
| 211 |
seeded: bool
|
|
|
|
| 80 |
markov_order: int = Field(default=4, ge=1, le=16)
|
| 81 |
seeded: bool
|
| 82 |
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
| 83 |
+
midi_input_id: str | None = Field(default=None, max_length=256)
|
| 84 |
+
midi_input_name: str | None = Field(default=None, max_length=256)
|
| 85 |
+
playback_choice: str | None = Field(default=None, max_length=512)
|
| 86 |
+
playback_choice_name: str | None = Field(default=None, max_length=256)
|
| 87 |
|
| 88 |
|
| 89 |
class CreateSessionResponse(BaseModel):
|
|
|
|
| 210 |
configuration: SessionConfiguration
|
| 211 |
|
| 212 |
|
| 213 |
+
class UpdateSessionPreferencesRequest(BaseModel):
|
| 214 |
+
midi_input_id: str | None = Field(default=None, max_length=256)
|
| 215 |
+
midi_input_name: str | None = Field(default=None, max_length=256)
|
| 216 |
+
playback_choice: str | None = Field(default=None, max_length=512)
|
| 217 |
+
playback_choice_name: str | None = Field(default=None, max_length=256)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class UpdateSessionPreferencesResponse(BaseModel):
|
| 221 |
+
session_id: str
|
| 222 |
+
updated_at: str
|
| 223 |
+
configuration: SessionConfiguration
|
| 224 |
+
|
| 225 |
+
|
| 226 |
class PublicConfigResponse(BaseModel):
|
| 227 |
app_name: str
|
| 228 |
seeded: bool
|
backend/app/session_manager.py
CHANGED
|
@@ -27,6 +27,8 @@ from .schemas import (
|
|
| 27 |
SessionMemorySummary,
|
| 28 |
UpdateSessionNameRequest,
|
| 29 |
UpdateSessionNameResponse,
|
|
|
|
|
|
|
| 30 |
UpdateSessionSettingsRequest,
|
| 31 |
UpdateSessionSettingsResponse,
|
| 32 |
UserSessionListItem,
|
|
@@ -81,6 +83,10 @@ class SessionManager:
|
|
| 81 |
markov_order=request.markov_order,
|
| 82 |
seeded=self.seeded,
|
| 83 |
display_name=None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
)
|
| 85 |
|
| 86 |
def _build_engine(self, configuration: SessionConfiguration) -> ContinuatorSessionEngine:
|
|
@@ -506,6 +512,38 @@ class SessionManager:
|
|
| 506 |
configuration=state.configuration,
|
| 507 |
)
|
| 508 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
|
| 510 |
__all__ = [
|
| 511 |
"MidiImportError",
|
|
|
|
| 27 |
SessionMemorySummary,
|
| 28 |
UpdateSessionNameRequest,
|
| 29 |
UpdateSessionNameResponse,
|
| 30 |
+
UpdateSessionPreferencesRequest,
|
| 31 |
+
UpdateSessionPreferencesResponse,
|
| 32 |
UpdateSessionSettingsRequest,
|
| 33 |
UpdateSessionSettingsResponse,
|
| 34 |
UserSessionListItem,
|
|
|
|
| 83 |
markov_order=request.markov_order,
|
| 84 |
seeded=self.seeded,
|
| 85 |
display_name=None,
|
| 86 |
+
midi_input_id=None,
|
| 87 |
+
midi_input_name=None,
|
| 88 |
+
playback_choice=None,
|
| 89 |
+
playback_choice_name=None,
|
| 90 |
)
|
| 91 |
|
| 92 |
def _build_engine(self, configuration: SessionConfiguration) -> ContinuatorSessionEngine:
|
|
|
|
| 512 |
configuration=state.configuration,
|
| 513 |
)
|
| 514 |
|
| 515 |
+
def update_session_preferences(
|
| 516 |
+
self,
|
| 517 |
+
session_id: str,
|
| 518 |
+
owner_user_id: str | None,
|
| 519 |
+
request: UpdateSessionPreferencesRequest,
|
| 520 |
+
) -> UpdateSessionPreferencesResponse:
|
| 521 |
+
state = self._require_session(session_id, owner_user_id)
|
| 522 |
+
updated_at = utc_now_iso()
|
| 523 |
+
|
| 524 |
+
def normalize(value: str | None) -> str | None:
|
| 525 |
+
if value is None:
|
| 526 |
+
return None
|
| 527 |
+
normalized = " ".join(value.split())
|
| 528 |
+
return normalized or None
|
| 529 |
+
|
| 530 |
+
update_fields = request.model_dump(exclude_unset=True)
|
| 531 |
+
normalized_fields = {
|
| 532 |
+
key: normalize(value) for key, value in update_fields.items()
|
| 533 |
+
}
|
| 534 |
+
state.configuration = state.configuration.model_copy(update=normalized_fields)
|
| 535 |
+
state.last_seen_at = updated_at
|
| 536 |
+
self.storage.update_session_metadata(
|
| 537 |
+
session_id=session_id,
|
| 538 |
+
metadata=state.configuration.model_dump(),
|
| 539 |
+
last_seen_at=updated_at,
|
| 540 |
+
)
|
| 541 |
+
return UpdateSessionPreferencesResponse(
|
| 542 |
+
session_id=session_id,
|
| 543 |
+
updated_at=updated_at,
|
| 544 |
+
configuration=state.configuration,
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
|
| 548 |
__all__ = [
|
| 549 |
"MidiImportError",
|
frontend/app.js
CHANGED
|
@@ -76,7 +76,6 @@ const elements = {
|
|
| 76 |
phraseGapMeterBar: document.querySelector("#phrase-gap-meter-bar"),
|
| 77 |
phraseGapMeterCopy: document.querySelector("#phrase-gap-meter-copy"),
|
| 78 |
selectedInputName: document.querySelector("#selected-input-name"),
|
| 79 |
-
selectedOutputName: document.querySelector("#selected-output-name"),
|
| 80 |
lastMidiEvent: document.querySelector("#last-midi-event"),
|
| 81 |
capturedEventCount: document.querySelector("#captured-event-count"),
|
| 82 |
capturedNoteCount: document.querySelector("#captured-note-count"),
|
|
@@ -1053,7 +1052,7 @@ function rendererHealthLabel() {
|
|
| 1053 |
return "Connect MIDI to inspect external outputs";
|
| 1054 |
}
|
| 1055 |
const output = midiOutputById(choice.targetId);
|
| 1056 |
-
return output ?
|
| 1057 |
}
|
| 1058 |
if (choice.renderer instanceof FaustPolyRenderer) {
|
| 1059 |
return choice.renderer.lastError
|
|
@@ -1386,10 +1385,6 @@ function setSelectedInputName(label) {
|
|
| 1386 |
elements.selectedInputName.textContent = label;
|
| 1387 |
}
|
| 1388 |
|
| 1389 |
-
function setSelectedOutputName(label) {
|
| 1390 |
-
elements.selectedOutputName.textContent = label;
|
| 1391 |
-
}
|
| 1392 |
-
|
| 1393 |
function setLastMidiEvent(label) {
|
| 1394 |
elements.lastMidiEvent.textContent = label;
|
| 1395 |
}
|
|
@@ -1631,6 +1626,34 @@ function describeSessionSettings(settings) {
|
|
| 1631 |
return [orderLabel, transposeLabel, memoryLabel, decayLabel];
|
| 1632 |
}
|
| 1633 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1634 |
function renderSessionSettingsSummary() {
|
| 1635 |
const labels = describeSessionSettings(readSessionSettingsFromControls());
|
| 1636 |
elements.settingsSummary.innerHTML = labels
|
|
@@ -1777,6 +1800,8 @@ function createSavedSessionsMarkup(items) {
|
|
| 1777 |
? ` · Reset ${formatTimestamp(item.last_reset_at)}`
|
| 1778 |
: "";
|
| 1779 |
const settingsLabel = describeSessionSettings(item.configuration).join(" · ");
|
|
|
|
|
|
|
| 1780 |
const displayName = sessionDisplayName(item);
|
| 1781 |
return `
|
| 1782 |
<div
|
|
@@ -1793,7 +1818,7 @@ function createSavedSessionsMarkup(items) {
|
|
| 1793 |
<strong>${displayName}</strong>
|
| 1794 |
<span>${activityLabel}</span>
|
| 1795 |
<span>Seen ${formatTimestamp(item.last_seen_at)}${resetLabel}</span>
|
| 1796 |
-
<span>${
|
| 1797 |
</span>
|
| 1798 |
</button>
|
| 1799 |
<button
|
|
@@ -1864,8 +1889,12 @@ function renderSavedSessionPreview() {
|
|
| 1864 |
`${pluralize(item.active_learned_phrase_count, "learned phrase")} active`;
|
| 1865 |
elements.savedSessionPreviewHistory.textContent =
|
| 1866 |
`${pluralize(item.phrase_count, "logged phrase")} · ${pluralize(item.input_phrase_count, "input")}`;
|
| 1867 |
-
elements.savedSessionPreviewSettings.textContent =
|
| 1868 |
-
describeSessionSettings(item.configuration).join(" · ")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1869 |
elements.savedSessionPreviewSeen.textContent =
|
| 1870 |
`${formatTimestamp(item.last_seen_at)}${item.last_reset_at ? ` · Reset ${formatTimestamp(item.last_reset_at)}` : ""}`;
|
| 1871 |
}
|
|
@@ -1896,6 +1925,61 @@ async function renamePreviewedSession() {
|
|
| 1896 |
setAuthMessage(`Renamed session to ${sessionDisplayName(item)}.`);
|
| 1897 |
}
|
| 1898 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1899 |
function attachSavedSessionEvents() {
|
| 1900 |
elements.mySessionsList.querySelectorAll("[data-owned-session-preview-id]").forEach((node) => {
|
| 1901 |
node.addEventListener("click", () => {
|
|
@@ -2641,12 +2725,46 @@ async function refreshSavedSessions() {
|
|
| 2641 |
renderSavedSessions(payload.items || []);
|
| 2642 |
}
|
| 2643 |
|
| 2644 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2645 |
state.sessionId = payload.session_id;
|
| 2646 |
state.sessionIsOwned = owned;
|
| 2647 |
state.sessionConfiguration = payload.configuration;
|
| 2648 |
elements.sessionId.textContent = payload.session_id;
|
| 2649 |
syncSettingsControls(payload.configuration);
|
|
|
|
| 2650 |
updateSessionActionState();
|
| 2651 |
setSessionStatus("Ready");
|
| 2652 |
renderPerformanceState();
|
|
@@ -2661,7 +2779,7 @@ async function openSavedSession(sessionId) {
|
|
| 2661 |
method: "POST",
|
| 2662 |
});
|
| 2663 |
state.previewedSavedSessionId = sessionId;
|
| 2664 |
-
useSessionPayload(payload, { owned: true });
|
| 2665 |
setControlView("perform");
|
| 2666 |
clearPhraseBuffers();
|
| 2667 |
await refreshSessionActivity();
|
|
@@ -2681,7 +2799,8 @@ async function createSession({ preservePhraseBuffers = false, announce = true }
|
|
| 2681 |
body: JSON.stringify(settings),
|
| 2682 |
});
|
| 2683 |
|
| 2684 |
-
useSessionPayload(payload, { owned: Boolean(state.authUser) });
|
|
|
|
| 2685 |
setControlView("perform");
|
| 2686 |
if (!preservePhraseBuffers) {
|
| 2687 |
clearPhraseBuffers();
|
|
@@ -2988,7 +3107,7 @@ async function playTestNote() {
|
|
| 2988 |
const payload = createTestNotePayload();
|
| 2989 |
renderGeneratedStats(payload);
|
| 2990 |
await playPayload(payload);
|
| 2991 |
-
setPhraseMessage(`Testing ${
|
| 2992 |
}
|
| 2993 |
|
| 2994 |
async function panicPlayback() {
|
|
@@ -4076,6 +4195,7 @@ async function startInfiniteMode() {
|
|
| 4076 |
function populatePlaybackChoices() {
|
| 4077 |
const outputs = state.midiAccess ? [...state.midiAccess.outputs.values()] : [];
|
| 4078 |
const previousChoiceValue = selectedPlaybackChoiceValue();
|
|
|
|
| 4079 |
const availableChoices = new Set([
|
| 4080 |
...localPlaybackRenderers.map((renderer) => encodePlaybackChoice(renderer.id)),
|
| 4081 |
...outputs.map((output) =>
|
|
@@ -4118,9 +4238,12 @@ function populatePlaybackChoices() {
|
|
| 4118 |
]
|
| 4119 |
.filter(Boolean)
|
| 4120 |
.join("");
|
| 4121 |
-
elements.midiOutputSelect.value =
|
| 4122 |
-
|
| 4123 |
-
|
|
|
|
|
|
|
|
|
|
| 4124 |
updateSelectedOutput();
|
| 4125 |
}
|
| 4126 |
|
|
@@ -4144,10 +4267,14 @@ async function populateMidiSelectors() {
|
|
| 4144 |
: `<option value="">No MIDI inputs found</option>`;
|
| 4145 |
|
| 4146 |
if (inputs.length) {
|
| 4147 |
-
const
|
| 4148 |
-
|
| 4149 |
-
|
| 4150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4151 |
} else {
|
| 4152 |
detachCurrentInput();
|
| 4153 |
setSelectedInputName("No MIDI input found");
|
|
@@ -4169,7 +4296,7 @@ function detachCurrentInput() {
|
|
| 4169 |
renderPerformanceState();
|
| 4170 |
}
|
| 4171 |
|
| 4172 |
-
async function attachInput(inputId) {
|
| 4173 |
detachCurrentInput();
|
| 4174 |
|
| 4175 |
if (!state.midiAccess || !inputId) {
|
|
@@ -4215,6 +4342,12 @@ async function attachInput(inputId) {
|
|
| 4215 |
setSelectedInputName(input.name || input.id);
|
| 4216 |
setMidiStatus(`Listening on ${input.name || input.id}`);
|
| 4217 |
renderPerformanceState();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4218 |
}
|
| 4219 |
|
| 4220 |
async function connectMidi() {
|
|
@@ -4234,7 +4367,6 @@ async function connectMidi() {
|
|
| 4234 |
}
|
| 4235 |
|
| 4236 |
function updateSelectedOutput() {
|
| 4237 |
-
setSelectedOutputName(playbackChoiceLabel());
|
| 4238 |
syncFaustRendererPanel();
|
| 4239 |
renderPerformanceState();
|
| 4240 |
}
|
|
@@ -4543,7 +4675,7 @@ function bindEvents() {
|
|
| 4543 |
|
| 4544 |
elements.midiInputSelect.addEventListener("change", async (event) => {
|
| 4545 |
try {
|
| 4546 |
-
await attachInput(event.target.value);
|
| 4547 |
setPhraseMessage(`MIDI input changed to ${elements.selectedInputName.textContent}.`);
|
| 4548 |
} catch (error) {
|
| 4549 |
setPhraseMessage(error.message, true);
|
|
@@ -4571,7 +4703,8 @@ function bindEvents() {
|
|
| 4571 |
refreshCustomFaustControlState();
|
| 4572 |
}
|
| 4573 |
}
|
| 4574 |
-
|
|
|
|
| 4575 |
} catch (error) {
|
| 4576 |
setPhraseMessage(error.message, true);
|
| 4577 |
} finally {
|
|
|
|
| 76 |
phraseGapMeterBar: document.querySelector("#phrase-gap-meter-bar"),
|
| 77 |
phraseGapMeterCopy: document.querySelector("#phrase-gap-meter-copy"),
|
| 78 |
selectedInputName: document.querySelector("#selected-input-name"),
|
|
|
|
| 79 |
lastMidiEvent: document.querySelector("#last-midi-event"),
|
| 80 |
capturedEventCount: document.querySelector("#captured-event-count"),
|
| 81 |
capturedNoteCount: document.querySelector("#captured-note-count"),
|
|
|
|
| 1052 |
return "Connect MIDI to inspect external outputs";
|
| 1053 |
}
|
| 1054 |
const output = midiOutputById(choice.targetId);
|
| 1055 |
+
return output ? "External MIDI ready" : "External MIDI output unavailable";
|
| 1056 |
}
|
| 1057 |
if (choice.renderer instanceof FaustPolyRenderer) {
|
| 1058 |
return choice.renderer.lastError
|
|
|
|
| 1385 |
elements.selectedInputName.textContent = label;
|
| 1386 |
}
|
| 1387 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1388 |
function setLastMidiEvent(label) {
|
| 1389 |
elements.lastMidiEvent.textContent = label;
|
| 1390 |
}
|
|
|
|
| 1626 |
return [orderLabel, transposeLabel, memoryLabel, decayLabel];
|
| 1627 |
}
|
| 1628 |
|
| 1629 |
+
function savedPlaybackPreferenceLabel(configuration) {
|
| 1630 |
+
if (!configuration?.playback_choice) {
|
| 1631 |
+
return null;
|
| 1632 |
+
}
|
| 1633 |
+
if (configuration.playback_choice_name) {
|
| 1634 |
+
return configuration.playback_choice_name;
|
| 1635 |
+
}
|
| 1636 |
+
|
| 1637 |
+
const { rendererId } = parsePlaybackChoice(configuration.playback_choice);
|
| 1638 |
+
if (rendererId === WEB_MIDI_RENDERER_ID) {
|
| 1639 |
+
return "External MIDI";
|
| 1640 |
+
}
|
| 1641 |
+
return playbackChoiceLabel(configuration.playback_choice);
|
| 1642 |
+
}
|
| 1643 |
+
|
| 1644 |
+
function describeSessionPreferences(configuration) {
|
| 1645 |
+
const labels = [];
|
| 1646 |
+
const inputName = configuration?.midi_input_name || configuration?.midi_input_id;
|
| 1647 |
+
const playbackName = savedPlaybackPreferenceLabel(configuration);
|
| 1648 |
+
if (inputName) {
|
| 1649 |
+
labels.push(`Input ${inputName}`);
|
| 1650 |
+
}
|
| 1651 |
+
if (playbackName) {
|
| 1652 |
+
labels.push(`Playback ${playbackName}`);
|
| 1653 |
+
}
|
| 1654 |
+
return labels;
|
| 1655 |
+
}
|
| 1656 |
+
|
| 1657 |
function renderSessionSettingsSummary() {
|
| 1658 |
const labels = describeSessionSettings(readSessionSettingsFromControls());
|
| 1659 |
elements.settingsSummary.innerHTML = labels
|
|
|
|
| 1800 |
? ` · Reset ${formatTimestamp(item.last_reset_at)}`
|
| 1801 |
: "";
|
| 1802 |
const settingsLabel = describeSessionSettings(item.configuration).join(" · ");
|
| 1803 |
+
const preferenceLabel = describeSessionPreferences(item.configuration).join(" · ");
|
| 1804 |
+
const detailLabel = [settingsLabel, preferenceLabel].filter(Boolean).join(" · ");
|
| 1805 |
const displayName = sessionDisplayName(item);
|
| 1806 |
return `
|
| 1807 |
<div
|
|
|
|
| 1818 |
<strong>${displayName}</strong>
|
| 1819 |
<span>${activityLabel}</span>
|
| 1820 |
<span>Seen ${formatTimestamp(item.last_seen_at)}${resetLabel}</span>
|
| 1821 |
+
<span>${detailLabel}</span>
|
| 1822 |
</span>
|
| 1823 |
</button>
|
| 1824 |
<button
|
|
|
|
| 1889 |
`${pluralize(item.active_learned_phrase_count, "learned phrase")} active`;
|
| 1890 |
elements.savedSessionPreviewHistory.textContent =
|
| 1891 |
`${pluralize(item.phrase_count, "logged phrase")} · ${pluralize(item.input_phrase_count, "input")}`;
|
| 1892 |
+
elements.savedSessionPreviewSettings.textContent = [
|
| 1893 |
+
describeSessionSettings(item.configuration).join(" · "),
|
| 1894 |
+
describeSessionPreferences(item.configuration).join(" · "),
|
| 1895 |
+
]
|
| 1896 |
+
.filter(Boolean)
|
| 1897 |
+
.join(" · ");
|
| 1898 |
elements.savedSessionPreviewSeen.textContent =
|
| 1899 |
`${formatTimestamp(item.last_seen_at)}${item.last_reset_at ? ` · Reset ${formatTimestamp(item.last_reset_at)}` : ""}`;
|
| 1900 |
}
|
|
|
|
| 1925 |
setAuthMessage(`Renamed session to ${sessionDisplayName(item)}.`);
|
| 1926 |
}
|
| 1927 |
|
| 1928 |
+
function updateSavedSessionConfiguration(sessionId, configuration, updatedAt = null) {
|
| 1929 |
+
const item = state.savedSessions.find((session) => session.session_id === sessionId);
|
| 1930 |
+
if (!item) {
|
| 1931 |
+
return;
|
| 1932 |
+
}
|
| 1933 |
+
item.configuration = configuration;
|
| 1934 |
+
if (updatedAt) {
|
| 1935 |
+
item.last_seen_at = updatedAt;
|
| 1936 |
+
}
|
| 1937 |
+
renderSavedSessions(state.savedSessions);
|
| 1938 |
+
}
|
| 1939 |
+
|
| 1940 |
+
function currentInputPreference() {
|
| 1941 |
+
if (!state.midiAccess || !state.activeInputId) {
|
| 1942 |
+
return {};
|
| 1943 |
+
}
|
| 1944 |
+
const input = state.midiAccess.inputs.get(state.activeInputId);
|
| 1945 |
+
if (!input) {
|
| 1946 |
+
return {};
|
| 1947 |
+
}
|
| 1948 |
+
return {
|
| 1949 |
+
midi_input_id: input.id,
|
| 1950 |
+
midi_input_name: input.name || input.id,
|
| 1951 |
+
};
|
| 1952 |
+
}
|
| 1953 |
+
|
| 1954 |
+
function currentPlaybackPreference() {
|
| 1955 |
+
return {
|
| 1956 |
+
playback_choice: selectedPlaybackChoiceValue(),
|
| 1957 |
+
playback_choice_name: playbackChoiceLabel(),
|
| 1958 |
+
};
|
| 1959 |
+
}
|
| 1960 |
+
|
| 1961 |
+
function currentSessionPreferences() {
|
| 1962 |
+
return {
|
| 1963 |
+
...currentInputPreference(),
|
| 1964 |
+
...currentPlaybackPreference(),
|
| 1965 |
+
};
|
| 1966 |
+
}
|
| 1967 |
+
|
| 1968 |
+
async function saveSessionPreferences(preferences) {
|
| 1969 |
+
if (!state.sessionId || !preferences || !Object.keys(preferences).length) {
|
| 1970 |
+
return null;
|
| 1971 |
+
}
|
| 1972 |
+
|
| 1973 |
+
const payload = await requestJson(`/api/sessions/${state.sessionId}/preferences`, {
|
| 1974 |
+
method: "PATCH",
|
| 1975 |
+
headers: { "Content-Type": "application/json" },
|
| 1976 |
+
body: JSON.stringify(preferences),
|
| 1977 |
+
});
|
| 1978 |
+
state.sessionConfiguration = payload.configuration;
|
| 1979 |
+
updateSavedSessionConfiguration(payload.session_id, payload.configuration, payload.updated_at);
|
| 1980 |
+
return payload;
|
| 1981 |
+
}
|
| 1982 |
+
|
| 1983 |
function attachSavedSessionEvents() {
|
| 1984 |
elements.mySessionsList.querySelectorAll("[data-owned-session-preview-id]").forEach((node) => {
|
| 1985 |
node.addEventListener("click", () => {
|
|
|
|
| 2725 |
renderSavedSessions(payload.items || []);
|
| 2726 |
}
|
| 2727 |
|
| 2728 |
+
function selectPlaybackPreference(configuration) {
|
| 2729 |
+
const preferredChoiceValue = configuration?.playback_choice;
|
| 2730 |
+
if (!preferredChoiceValue) {
|
| 2731 |
+
return false;
|
| 2732 |
+
}
|
| 2733 |
+
|
| 2734 |
+
const options = new Set(
|
| 2735 |
+
[...elements.midiOutputSelect.options].map((option) => option.value),
|
| 2736 |
+
);
|
| 2737 |
+
if (!options.has(preferredChoiceValue)) {
|
| 2738 |
+
return false;
|
| 2739 |
+
}
|
| 2740 |
+
|
| 2741 |
+
elements.midiOutputSelect.value = preferredChoiceValue;
|
| 2742 |
+
updateSelectedOutput();
|
| 2743 |
+
return true;
|
| 2744 |
+
}
|
| 2745 |
+
|
| 2746 |
+
async function selectMidiInputPreference(configuration) {
|
| 2747 |
+
const preferredInputId = configuration?.midi_input_id;
|
| 2748 |
+
if (!state.midiAccess || !preferredInputId || !state.midiAccess.inputs.has(preferredInputId)) {
|
| 2749 |
+
return false;
|
| 2750 |
+
}
|
| 2751 |
+
|
| 2752 |
+
await attachInput(preferredInputId, { savePreference: false });
|
| 2753 |
+
return true;
|
| 2754 |
+
}
|
| 2755 |
+
|
| 2756 |
+
async function restoreSessionPreferences(configuration) {
|
| 2757 |
+
selectPlaybackPreference(configuration);
|
| 2758 |
+
await selectMidiInputPreference(configuration);
|
| 2759 |
+
}
|
| 2760 |
+
|
| 2761 |
+
async function useSessionPayload(payload, { owned = Boolean(state.authUser) } = {}) {
|
| 2762 |
state.sessionId = payload.session_id;
|
| 2763 |
state.sessionIsOwned = owned;
|
| 2764 |
state.sessionConfiguration = payload.configuration;
|
| 2765 |
elements.sessionId.textContent = payload.session_id;
|
| 2766 |
syncSettingsControls(payload.configuration);
|
| 2767 |
+
await restoreSessionPreferences(payload.configuration);
|
| 2768 |
updateSessionActionState();
|
| 2769 |
setSessionStatus("Ready");
|
| 2770 |
renderPerformanceState();
|
|
|
|
| 2779 |
method: "POST",
|
| 2780 |
});
|
| 2781 |
state.previewedSavedSessionId = sessionId;
|
| 2782 |
+
await useSessionPayload(payload, { owned: true });
|
| 2783 |
setControlView("perform");
|
| 2784 |
clearPhraseBuffers();
|
| 2785 |
await refreshSessionActivity();
|
|
|
|
| 2799 |
body: JSON.stringify(settings),
|
| 2800 |
});
|
| 2801 |
|
| 2802 |
+
await useSessionPayload(payload, { owned: Boolean(state.authUser) });
|
| 2803 |
+
await saveSessionPreferences(currentSessionPreferences());
|
| 2804 |
setControlView("perform");
|
| 2805 |
if (!preservePhraseBuffers) {
|
| 2806 |
clearPhraseBuffers();
|
|
|
|
| 3107 |
const payload = createTestNotePayload();
|
| 3108 |
renderGeneratedStats(payload);
|
| 3109 |
await playPayload(payload);
|
| 3110 |
+
setPhraseMessage(`Testing ${playbackChoiceLabel()}.`);
|
| 3111 |
}
|
| 3112 |
|
| 3113 |
async function panicPlayback() {
|
|
|
|
| 4195 |
function populatePlaybackChoices() {
|
| 4196 |
const outputs = state.midiAccess ? [...state.midiAccess.outputs.values()] : [];
|
| 4197 |
const previousChoiceValue = selectedPlaybackChoiceValue();
|
| 4198 |
+
const preferredChoiceValue = state.sessionConfiguration?.playback_choice || null;
|
| 4199 |
const availableChoices = new Set([
|
| 4200 |
...localPlaybackRenderers.map((renderer) => encodePlaybackChoice(renderer.id)),
|
| 4201 |
...outputs.map((output) =>
|
|
|
|
| 4238 |
]
|
| 4239 |
.filter(Boolean)
|
| 4240 |
.join("");
|
| 4241 |
+
elements.midiOutputSelect.value =
|
| 4242 |
+
preferredChoiceValue && availableChoices.has(preferredChoiceValue)
|
| 4243 |
+
? preferredChoiceValue
|
| 4244 |
+
: availableChoices.has(previousChoiceValue)
|
| 4245 |
+
? previousChoiceValue
|
| 4246 |
+
: DEFAULT_PLAYBACK_CHOICE;
|
| 4247 |
updateSelectedOutput();
|
| 4248 |
}
|
| 4249 |
|
|
|
|
| 4267 |
: `<option value="">No MIDI inputs found</option>`;
|
| 4268 |
|
| 4269 |
if (inputs.length) {
|
| 4270 |
+
const preferredInputId = state.sessionConfiguration?.midi_input_id || null;
|
| 4271 |
+
const inputId =
|
| 4272 |
+
preferredInputId && state.midiAccess.inputs.has(preferredInputId)
|
| 4273 |
+
? preferredInputId
|
| 4274 |
+
: state.midiAccess.inputs.has(previousInputId)
|
| 4275 |
+
? previousInputId
|
| 4276 |
+
: inputs[0].id;
|
| 4277 |
+
await attachInput(inputId, { savePreference: false });
|
| 4278 |
} else {
|
| 4279 |
detachCurrentInput();
|
| 4280 |
setSelectedInputName("No MIDI input found");
|
|
|
|
| 4296 |
renderPerformanceState();
|
| 4297 |
}
|
| 4298 |
|
| 4299 |
+
async function attachInput(inputId, { savePreference = false } = {}) {
|
| 4300 |
detachCurrentInput();
|
| 4301 |
|
| 4302 |
if (!state.midiAccess || !inputId) {
|
|
|
|
| 4342 |
setSelectedInputName(input.name || input.id);
|
| 4343 |
setMidiStatus(`Listening on ${input.name || input.id}`);
|
| 4344 |
renderPerformanceState();
|
| 4345 |
+
if (savePreference) {
|
| 4346 |
+
await saveSessionPreferences({
|
| 4347 |
+
midi_input_id: input.id,
|
| 4348 |
+
midi_input_name: input.name || input.id,
|
| 4349 |
+
});
|
| 4350 |
+
}
|
| 4351 |
}
|
| 4352 |
|
| 4353 |
async function connectMidi() {
|
|
|
|
| 4367 |
}
|
| 4368 |
|
| 4369 |
function updateSelectedOutput() {
|
|
|
|
| 4370 |
syncFaustRendererPanel();
|
| 4371 |
renderPerformanceState();
|
| 4372 |
}
|
|
|
|
| 4675 |
|
| 4676 |
elements.midiInputSelect.addEventListener("change", async (event) => {
|
| 4677 |
try {
|
| 4678 |
+
await attachInput(event.target.value, { savePreference: true });
|
| 4679 |
setPhraseMessage(`MIDI input changed to ${elements.selectedInputName.textContent}.`);
|
| 4680 |
} catch (error) {
|
| 4681 |
setPhraseMessage(error.message, true);
|
|
|
|
| 4703 |
refreshCustomFaustControlState();
|
| 4704 |
}
|
| 4705 |
}
|
| 4706 |
+
await saveSessionPreferences(currentPlaybackPreference());
|
| 4707 |
+
setPhraseMessage(`Playback renderer set to ${playbackChoiceLabel()}.`);
|
| 4708 |
} catch (error) {
|
| 4709 |
setPhraseMessage(error.message, true);
|
| 4710 |
} finally {
|
frontend/index.html
CHANGED
|
@@ -479,10 +479,6 @@
|
|
| 479 |
</select>
|
| 480 |
</div>
|
| 481 |
<div class="playback-status-row">
|
| 482 |
-
<span class="playback-status-chip">
|
| 483 |
-
<span>Using</span>
|
| 484 |
-
<strong id="selected-output-name">Browser Triangle</strong>
|
| 485 |
-
</span>
|
| 486 |
<span class="playback-status-chip">
|
| 487 |
<span>Health</span>
|
| 488 |
<strong id="renderer-health">Browser renderer ready</strong>
|
|
@@ -1010,6 +1006,6 @@
|
|
| 1010 |
</section>
|
| 1011 |
</main>
|
| 1012 |
|
| 1013 |
-
<script type="module" src="/assets/app.js?v=20260425-
|
| 1014 |
</body>
|
| 1015 |
</html>
|
|
|
|
| 479 |
</select>
|
| 480 |
</div>
|
| 481 |
<div class="playback-status-row">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
<span class="playback-status-chip">
|
| 483 |
<span>Health</span>
|
| 484 |
<strong id="renderer-health">Browser renderer ready</strong>
|
|
|
|
| 1006 |
</section>
|
| 1007 |
</main>
|
| 1008 |
|
| 1009 |
+
<script type="module" src="/assets/app.js?v=20260425-session-preferences"></script>
|
| 1010 |
</body>
|
| 1011 |
</html>
|