pachet commited on
Commit
d4cdcc1
·
1 Parent(s): 952b9cf

Persist session MIDI preferences

Browse files
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 ? `External MIDI ready: ${output.name || output.id}` : "External MIDI output unavailable";
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>${settingsLabel}</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 useSessionPayload(payload, { owned = Boolean(state.authUser) } = {}) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ${elements.selectedOutputName.textContent}.`);
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 = availableChoices.has(previousChoiceValue)
4122
- ? previousChoiceValue
4123
- : DEFAULT_PLAYBACK_CHOICE;
 
 
 
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 inputId = state.midiAccess.inputs.has(previousInputId)
4148
- ? previousInputId
4149
- : inputs[0].id;
4150
- await attachInput(inputId);
 
 
 
 
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
- setPhraseMessage(`Playback renderer set to ${elements.selectedOutputName.textContent}.`);
 
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-playback-ux"></script>
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>