pachet commited on
Commit
e94f2e3
·
1 Parent(s): 0e7655f

Remember MIDI playback output preferences

Browse files
Files changed (2) hide show
  1. frontend/app.js +404 -16
  2. frontend/index.html +7 -1
frontend/app.js CHANGED
@@ -41,6 +41,8 @@ const FAUST_CUSTOM_TEMPLATE_DSP_URL = "/assets/faust/custom-poly-template.dsp";
41
  const PHRASE_TIMEOUT_STORAGE_KEY = "continuator.phrase.timeout.ms";
42
  const FAUST_CUSTOM_SOURCE_STORAGE_KEY = "continuator.faust.custom.source";
43
  const FAUST_CUSTOM_VALUES_STORAGE_KEY = "continuator.faust.custom.values";
 
 
44
  const FAUST_UI_CONTROL_TYPES = new Set([
45
  "hslider",
46
  "vslider",
@@ -133,6 +135,7 @@ const elements = {
133
  midiInputSelect: document.querySelector("#midi-input-select"),
134
  phraseTimeoutInput: document.querySelector("#phrase-timeout-input"),
135
  midiOutputSelect: document.querySelector("#midi-output-select"),
 
136
  faustRendererPanel: document.querySelector("#faust-renderer-panel"),
137
  faustClavierPanel: document.querySelector("#faust-clavier-panel"),
138
  faustCustomPanel: document.querySelector("#faust-custom-panel"),
@@ -253,6 +256,9 @@ const state = {
253
  liveMonitorPlaybackPromise: null,
254
  liveMonitorChoiceValue: null,
255
  liveMonitorToken: 0,
 
 
 
256
  };
257
 
258
  function midiToFrequency(note) {
@@ -351,12 +357,17 @@ class BrowserAudioRenderer {
351
  this.context = new window.AudioContext();
352
  this.master = new GainNode(this.context, { gain: this.masterGain });
353
  this.master.connect(this.context.destination);
 
354
  }
355
  if (this.context.state === "suspended") {
356
  await this.context.resume();
357
  }
358
  }
359
 
 
 
 
 
360
  async createPlaybackSession() {
361
  await this.ensureContext();
362
  return {};
@@ -474,6 +485,212 @@ function safeLocalStorageSet(key, value) {
474
  }
475
  }
476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  function createFaustParamPathMap(ui) {
478
  const paramPaths = new Map();
479
  walkFaustUi(ui, (item) => {
@@ -576,12 +793,17 @@ class FaustPolyRenderer {
576
  async ensureContext() {
577
  if (!this.context) {
578
  this.context = new window.AudioContext();
 
579
  }
580
  if (this.context.state === "suspended") {
581
  await this.context.resume();
582
  }
583
  }
584
 
 
 
 
 
585
  async loadCode(forceRefresh = false) {
586
  if (this.getCode) {
587
  const code = await this.getCode();
@@ -2392,6 +2614,9 @@ function currentSessionPreferences() {
2392
  }
2393
 
2394
  async function saveSessionPreferences(preferences) {
 
 
 
2395
  if (!state.sessionId || !preferences || !Object.keys(preferences).length) {
2396
  return null;
2397
  }
@@ -2426,6 +2651,9 @@ function attachSavedSessionEvents() {
2426
 
2427
  function renderSavedSessions(items) {
2428
  state.savedSessions = items;
 
 
 
2429
  if (
2430
  state.previewedSavedSessionId &&
2431
  !items.some((item) => item.session_id === state.previewedSavedSessionId)
@@ -2947,10 +3175,7 @@ function drawPianoRoll(canvas, notes, accent, emptyLabel, options = {}) {
2947
 
2948
  const minPitch = Math.max(24, Math.min(...notes.map((note) => note.pitch)) - 2);
2949
  const maxPitch = Math.min(108, Math.max(...notes.map((note) => note.pitch)) + 2);
2950
- const totalDuration = Math.max(
2951
- 2,
2952
- ...notes.map((note) => note.end_seconds || note.start_seconds + note.duration_seconds),
2953
- );
2954
  const pitchRange = Math.max(1, maxPitch - minPitch + 1);
2955
 
2956
  const playbackSeconds =
@@ -2978,8 +3203,11 @@ function drawPianoRoll(canvas, notes, accent, emptyLabel, options = {}) {
2978
 
2979
  ctx.shadowBlur = 0;
2980
 
2981
- if (Number.isFinite(Number(options.progressRatio))) {
2982
- const progressRatio = Math.min(1, Math.max(0, Number(options.progressRatio)));
 
 
 
2983
  const x = Math.round(progressRatio * width) + 0.5;
2984
  ctx.strokeStyle = "rgba(255, 245, 212, 0.86)";
2985
  ctx.lineWidth = 2;
@@ -2997,6 +3225,13 @@ function drawPianoRoll(canvas, notes, accent, emptyLabel, options = {}) {
2997
  }
2998
  }
2999
 
 
 
 
 
 
 
 
3000
  function payloadNotes(payload) {
3001
  const notes = Array.isArray(payload?.notes)
3002
  ? payload.notes.filter(
@@ -3071,6 +3306,9 @@ function eventsToNotes(events) {
3071
  async function refreshAuthState() {
3072
  const payload = await requestJson("/api/auth/me");
3073
  state.authUser = payload?.user || null;
 
 
 
3074
  syncAuthUI();
3075
  if (state.authUser) {
3076
  await refreshSavedSessions();
@@ -3080,6 +3318,7 @@ async function refreshAuthState() {
3080
  return;
3081
  }
3082
 
 
3083
  renderSavedSessions([]);
3084
  if (!state.sessionId) {
3085
  clearCurrentSessionState(
@@ -3111,6 +3350,9 @@ async function submitAuth(mode) {
3111
  });
3112
 
3113
  state.authUser = payload.user;
 
 
 
3114
  elements.authUsernameInput.value = payload.user.username;
3115
  syncAuthUI();
3116
  await refreshSavedSessions();
@@ -3143,6 +3385,10 @@ async function submitAuth(mode) {
3143
  async function logoutUser() {
3144
  await requestJson("/api/auth/logout", { method: "POST" });
3145
  state.authUser = null;
 
 
 
 
3146
  syncAuthUI();
3147
  renderSavedSessions([]);
3148
  setAccountPanelOpen(false);
@@ -3165,7 +3411,10 @@ async function refreshSavedSessions() {
3165
  }
3166
 
3167
  function selectPlaybackPreference(configuration) {
3168
- const preferredChoiceValue = configuration?.playback_choice;
 
 
 
3169
  if (!preferredChoiceValue) {
3170
  return false;
3171
  }
@@ -3179,9 +3428,96 @@ function selectPlaybackPreference(configuration) {
3179
 
3180
  elements.midiOutputSelect.value = preferredChoiceValue;
3181
  updateSelectedOutput();
 
 
 
 
 
3182
  return true;
3183
  }
3184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3185
  async function selectMidiInputPreference(configuration) {
3186
  const preferredInputId = configuration?.midi_input_id;
3187
  if (!preferredInputId) {
@@ -3246,7 +3582,13 @@ async function createSession({ preservePhraseBuffers = false, announce = true }
3246
  });
3247
 
3248
  await useSessionPayload(payload, { owned: Boolean(state.authUser) });
3249
- await saveSessionPreferences(currentSessionPreferences());
 
 
 
 
 
 
3250
  setControlView("perform");
3251
  if (!preservePhraseBuffers) {
3252
  clearPhraseBuffers();
@@ -4189,11 +4531,12 @@ function startPlaybackVisualization(
4189
  renderPerformanceState();
4190
  const token = ++state.playbackVisualizationToken;
4191
  const startedAtMs = window.performance.now();
4192
- const totalDurationSeconds = Math.max(
4193
  0.001,
4194
  ...notes.map((note) => note.end_seconds || note.start_seconds + note.duration_seconds),
4195
  );
4196
- const safeDurationMs = Math.max(1, Number(durationMs) || totalDurationSeconds * 1000);
 
4197
  state.playbackVisualizationDisplayEndsAtMs = startedAtMs + safeDurationMs;
4198
  elements.outputPlayhead.hidden = rollKind !== "output";
4199
 
@@ -4203,10 +4546,12 @@ function startPlaybackVisualization(
4203
  }
4204
  const elapsedMs = Math.max(0, window.performance.now() - startedAtMs);
4205
  const progressRatio = Math.min(1, elapsedMs / safeDurationMs);
 
 
4206
  if (rollKind === "output") {
4207
  elements.outputPlayhead.hidden = false;
4208
  elements.outputPlayhead.style.transform = `translateX(${Math.round(
4209
- progressRatio * outputPlayheadMaxX(),
4210
  )}px)`;
4211
  }
4212
  drawPianoRoll(
@@ -4215,8 +4560,8 @@ function startPlaybackVisualization(
4215
  accent,
4216
  emptyLabel,
4217
  {
4218
- progressRatio,
4219
- playbackSeconds: progressRatio * totalDurationSeconds,
4220
  },
4221
  );
4222
  if (progressRatio >= 1) {
@@ -4647,7 +4992,10 @@ async function startInfiniteMode() {
4647
  function populatePlaybackChoices() {
4648
  const outputs = state.midiAccess ? [...state.midiAccess.outputs.values()] : [];
4649
  const previousChoiceValue = selectedPlaybackChoiceValue();
4650
- const preferredChoiceValue = state.sessionConfiguration?.playback_choice || null;
 
 
 
4651
  const availableChoices = new Set([
4652
  ...localPlaybackRenderers.map((renderer) => encodePlaybackChoice(renderer.id)),
4653
  ...outputs.map((output) =>
@@ -4808,10 +5156,20 @@ async function connectMidi() {
4808
  }
4809
 
4810
  state.midiAccess = await navigator.requestMIDIAccess({ sysex: false });
4811
- state.midiAccess.onstatechange = () => {
4812
- void populateMidiSelectors();
 
 
 
 
 
 
 
4813
  };
4814
  await populateMidiSelectors();
 
 
 
4815
  if (!state.activeInputId) {
4816
  setMidiStatus("Connected / choose input");
4817
  }
@@ -5249,6 +5607,25 @@ function bindEvents() {
5249
  }
5250
  });
5251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5252
  faustClavierControls.forEach((control) => {
5253
  control.input.addEventListener("input", () => {
5254
  faustClavierRenderer.setControlValue(control.key, control.input.value);
@@ -5397,6 +5774,7 @@ function bindEvents() {
5397
 
5398
  async function initialize() {
5399
  bindEvents();
 
5400
  try {
5401
  await initializeCustomFaustSource();
5402
  } catch (error) {
@@ -5408,6 +5786,16 @@ async function initialize() {
5408
  renderSavedSessions([]);
5409
  clearCurrentSessionState();
5410
  setControlView("perform");
 
 
 
 
 
 
 
 
 
 
5411
  setSelectedInputName("No MIDI input selected");
5412
  updateSelectedOutput();
5413
  syncFaustRendererPanel();
 
41
  const PHRASE_TIMEOUT_STORAGE_KEY = "continuator.phrase.timeout.ms";
42
  const FAUST_CUSTOM_SOURCE_STORAGE_KEY = "continuator.faust.custom.source";
43
  const FAUST_CUSTOM_VALUES_STORAGE_KEY = "continuator.faust.custom.values";
44
+ const PLAYBACK_PREFERENCE_STORAGE_PREFIX = "continuator.playback.preference";
45
+ const AUDIO_OUTPUT_PREFERENCE_STORAGE_PREFIX = "continuator.audio.output.preference";
46
  const FAUST_UI_CONTROL_TYPES = new Set([
47
  "hslider",
48
  "vslider",
 
135
  midiInputSelect: document.querySelector("#midi-input-select"),
136
  phraseTimeoutInput: document.querySelector("#phrase-timeout-input"),
137
  midiOutputSelect: document.querySelector("#midi-output-select"),
138
+ audioOutputSelect: document.querySelector("#audio-output-select"),
139
  faustRendererPanel: document.querySelector("#faust-renderer-panel"),
140
  faustClavierPanel: document.querySelector("#faust-clavier-panel"),
141
  faustCustomPanel: document.querySelector("#faust-custom-panel"),
 
256
  liveMonitorPlaybackPromise: null,
257
  liveMonitorChoiceValue: null,
258
  liveMonitorToken: 0,
259
+ userPlaybackPreference: null,
260
+ userAudioOutputPreference: null,
261
+ audioOutputDevices: [],
262
  };
263
 
264
  function midiToFrequency(note) {
 
357
  this.context = new window.AudioContext();
358
  this.master = new GainNode(this.context, { gain: this.masterGain });
359
  this.master.connect(this.context.destination);
360
+ await applyAudioOutputToContext(this.context);
361
  }
362
  if (this.context.state === "suspended") {
363
  await this.context.resume();
364
  }
365
  }
366
 
367
+ async applyAudioOutputPreference() {
368
+ return applyAudioOutputToContext(this.context);
369
+ }
370
+
371
  async createPlaybackSession() {
372
  await this.ensureContext();
373
  return {};
 
485
  }
486
  }
487
 
488
+ function playbackPreferenceStorageKey() {
489
+ const userId = state.authUser?.id || "guest";
490
+ return `${PLAYBACK_PREFERENCE_STORAGE_PREFIX}.${userId}`;
491
+ }
492
+
493
+ function audioOutputPreferenceStorageKey() {
494
+ const userId = state.authUser?.id || "guest";
495
+ return `${AUDIO_OUTPUT_PREFERENCE_STORAGE_PREFIX}.${userId}`;
496
+ }
497
+
498
+ function parseStoredPlaybackPreference(value) {
499
+ if (!value) {
500
+ return null;
501
+ }
502
+ try {
503
+ const parsed = JSON.parse(value);
504
+ const playbackChoice = String(parsed?.playback_choice || "").trim();
505
+ if (!playbackChoice) {
506
+ return null;
507
+ }
508
+ const playbackName = String(parsed?.playback_choice_name || "").trim();
509
+ return {
510
+ playback_choice: playbackChoice,
511
+ playback_choice_name: playbackName || null,
512
+ };
513
+ } catch {
514
+ return null;
515
+ }
516
+ }
517
+
518
+ function loadStoredPlaybackPreference() {
519
+ return parseStoredPlaybackPreference(safeLocalStorageGet(playbackPreferenceStorageKey()));
520
+ }
521
+
522
+ function rememberPlaybackPreference(preference) {
523
+ const playbackChoice = String(preference?.playback_choice || "").trim();
524
+ if (!playbackChoice) {
525
+ return;
526
+ }
527
+ const playbackName = String(preference?.playback_choice_name || "").trim();
528
+ state.userPlaybackPreference = {
529
+ playback_choice: playbackChoice,
530
+ playback_choice_name: playbackName || null,
531
+ };
532
+ safeLocalStorageSet(
533
+ playbackPreferenceStorageKey(),
534
+ JSON.stringify(state.userPlaybackPreference),
535
+ );
536
+ }
537
+
538
+ function latestSavedPlaybackPreference(items = state.savedSessions) {
539
+ const item =
540
+ items.find((candidate) => {
541
+ const choice = candidate?.configuration?.playback_choice;
542
+ return parsePlaybackChoice(choice).rendererId === WEB_MIDI_RENDERER_ID;
543
+ }) ||
544
+ items.find((candidate) => candidate?.configuration?.playback_choice);
545
+ if (!item) {
546
+ return null;
547
+ }
548
+ return {
549
+ playback_choice: item.configuration.playback_choice,
550
+ playback_choice_name: item.configuration.playback_choice_name || null,
551
+ };
552
+ }
553
+
554
+ function refreshUserPlaybackPreferenceFromSavedSessions(items = state.savedSessions) {
555
+ const storedPreference = loadStoredPlaybackPreference();
556
+ const savedPreference = latestSavedPlaybackPreference(items);
557
+ const storedRendererId = parsePlaybackChoice(storedPreference?.playback_choice).rendererId;
558
+ const savedRendererId = parsePlaybackChoice(savedPreference?.playback_choice).rendererId;
559
+ if (
560
+ savedPreference &&
561
+ storedRendererId !== WEB_MIDI_RENDERER_ID &&
562
+ savedRendererId === WEB_MIDI_RENDERER_ID
563
+ ) {
564
+ rememberPlaybackPreference(savedPreference);
565
+ return;
566
+ }
567
+ if (storedPreference) {
568
+ state.userPlaybackPreference = storedPreference;
569
+ return;
570
+ }
571
+ if (savedPreference) {
572
+ rememberPlaybackPreference(savedPreference);
573
+ }
574
+ }
575
+
576
+ function parseStoredAudioOutputPreference(value) {
577
+ if (!value) {
578
+ return null;
579
+ }
580
+ try {
581
+ const parsed = JSON.parse(value);
582
+ const deviceId = String(parsed?.device_id || "").trim();
583
+ const deviceName = String(parsed?.device_name || "").trim();
584
+ if (!deviceId) {
585
+ return null;
586
+ }
587
+ return {
588
+ device_id: deviceId,
589
+ device_name: deviceName || null,
590
+ };
591
+ } catch {
592
+ return null;
593
+ }
594
+ }
595
+
596
+ function loadStoredAudioOutputPreference() {
597
+ return parseStoredAudioOutputPreference(
598
+ safeLocalStorageGet(audioOutputPreferenceStorageKey()),
599
+ );
600
+ }
601
+
602
+ function rememberAudioOutputPreference(preference) {
603
+ const deviceId = String(preference?.device_id || "").trim();
604
+ if (!deviceId) {
605
+ return;
606
+ }
607
+ const deviceName = String(preference?.device_name || "").trim();
608
+ state.userAudioOutputPreference = {
609
+ device_id: deviceId,
610
+ device_name: deviceName || null,
611
+ };
612
+ safeLocalStorageSet(
613
+ audioOutputPreferenceStorageKey(),
614
+ JSON.stringify(state.userAudioOutputPreference),
615
+ );
616
+ }
617
+
618
+ function preferredAudioOutputDeviceId() {
619
+ return state.userAudioOutputPreference?.device_id || "default";
620
+ }
621
+
622
+ function audioOutputIsSupported() {
623
+ return (
624
+ Boolean(navigator.mediaDevices?.enumerateDevices) &&
625
+ typeof window.AudioContext?.prototype?.setSinkId === "function"
626
+ );
627
+ }
628
+
629
+ async function applyAudioOutputToContext(context) {
630
+ if (!context || typeof context.setSinkId !== "function") {
631
+ return false;
632
+ }
633
+ await context.setSinkId(preferredAudioOutputDeviceId());
634
+ return true;
635
+ }
636
+
637
+ async function applyPreferredAudioOutput() {
638
+ const renderers = [...localPlaybackRenderers, faustClavierRenderer, customFaustRenderer];
639
+ await Promise.all(
640
+ renderers.map((renderer) =>
641
+ renderer.applyAudioOutputPreference?.().catch((error) => {
642
+ setPhraseMessage(error.message, true);
643
+ }),
644
+ ),
645
+ );
646
+ }
647
+
648
+ function populateAudioOutputChoices() {
649
+ const supported = audioOutputIsSupported();
650
+ const preferredDeviceId = preferredAudioOutputDeviceId();
651
+ const devices = state.audioOutputDevices || [];
652
+ const choices = [
653
+ { deviceId: "default", label: "System default" },
654
+ ...devices
655
+ .filter((device) => device.deviceId && device.deviceId !== "default")
656
+ .map((device, index) => ({
657
+ deviceId: device.deviceId,
658
+ label: device.label || `Sound output ${index + 1}`,
659
+ })),
660
+ ];
661
+ const availableDeviceIds = new Set(choices.map((choice) => choice.deviceId));
662
+
663
+ elements.audioOutputSelect.disabled = !supported;
664
+ elements.audioOutputSelect.replaceChildren(
665
+ ...choices.map((choice) => {
666
+ const option = document.createElement("option");
667
+ option.value = choice.deviceId;
668
+ option.textContent = choice.label;
669
+ return option;
670
+ }),
671
+ );
672
+ elements.audioOutputSelect.value = availableDeviceIds.has(preferredDeviceId)
673
+ ? preferredDeviceId
674
+ : "default";
675
+ if (!supported) {
676
+ elements.audioOutputSelect.title =
677
+ "This browser does not expose app-level audio output selection.";
678
+ } else {
679
+ elements.audioOutputSelect.title = "";
680
+ }
681
+ }
682
+
683
+ async function refreshAudioOutputDevices() {
684
+ if (!navigator.mediaDevices?.enumerateDevices) {
685
+ state.audioOutputDevices = [];
686
+ populateAudioOutputChoices();
687
+ return;
688
+ }
689
+ const devices = await navigator.mediaDevices.enumerateDevices();
690
+ state.audioOutputDevices = devices.filter((device) => device.kind === "audiooutput");
691
+ populateAudioOutputChoices();
692
+ }
693
+
694
  function createFaustParamPathMap(ui) {
695
  const paramPaths = new Map();
696
  walkFaustUi(ui, (item) => {
 
793
  async ensureContext() {
794
  if (!this.context) {
795
  this.context = new window.AudioContext();
796
+ await applyAudioOutputToContext(this.context);
797
  }
798
  if (this.context.state === "suspended") {
799
  await this.context.resume();
800
  }
801
  }
802
 
803
+ async applyAudioOutputPreference() {
804
+ return applyAudioOutputToContext(this.context);
805
+ }
806
+
807
  async loadCode(forceRefresh = false) {
808
  if (this.getCode) {
809
  const code = await this.getCode();
 
2614
  }
2615
 
2616
  async function saveSessionPreferences(preferences) {
2617
+ if (preferences?.playback_choice) {
2618
+ rememberPlaybackPreference(preferences);
2619
+ }
2620
  if (!state.sessionId || !preferences || !Object.keys(preferences).length) {
2621
  return null;
2622
  }
 
2651
 
2652
  function renderSavedSessions(items) {
2653
  state.savedSessions = items;
2654
+ if (state.authUser) {
2655
+ refreshUserPlaybackPreferenceFromSavedSessions(items);
2656
+ }
2657
  if (
2658
  state.previewedSavedSessionId &&
2659
  !items.some((item) => item.session_id === state.previewedSavedSessionId)
 
3175
 
3176
  const minPitch = Math.max(24, Math.min(...notes.map((note) => note.pitch)) - 2);
3177
  const maxPitch = Math.min(108, Math.max(...notes.map((note) => note.pitch)) + 2);
3178
+ const totalDuration = pianoRollDurationSeconds(notes);
 
 
 
3179
  const pitchRange = Math.max(1, maxPitch - minPitch + 1);
3180
 
3181
  const playbackSeconds =
 
3203
 
3204
  ctx.shadowBlur = 0;
3205
 
3206
+ const playbackProgressRatio = Number.isFinite(Number(playbackSeconds))
3207
+ ? playbackSeconds / totalDuration
3208
+ : Number(options.progressRatio);
3209
+ if (Number.isFinite(playbackProgressRatio)) {
3210
+ const progressRatio = Math.min(1, Math.max(0, playbackProgressRatio));
3211
  const x = Math.round(progressRatio * width) + 0.5;
3212
  ctx.strokeStyle = "rgba(255, 245, 212, 0.86)";
3213
  ctx.lineWidth = 2;
 
3225
  }
3226
  }
3227
 
3228
+ function pianoRollDurationSeconds(notes) {
3229
+ return Math.max(
3230
+ 2,
3231
+ ...notes.map((note) => note.end_seconds || note.start_seconds + note.duration_seconds),
3232
+ );
3233
+ }
3234
+
3235
  function payloadNotes(payload) {
3236
  const notes = Array.isArray(payload?.notes)
3237
  ? payload.notes.filter(
 
3306
  async function refreshAuthState() {
3307
  const payload = await requestJson("/api/auth/me");
3308
  state.authUser = payload?.user || null;
3309
+ state.userAudioOutputPreference = loadStoredAudioOutputPreference();
3310
+ populateAudioOutputChoices();
3311
+ await applyPreferredAudioOutput();
3312
  syncAuthUI();
3313
  if (state.authUser) {
3314
  await refreshSavedSessions();
 
3318
  return;
3319
  }
3320
 
3321
+ state.userPlaybackPreference = loadStoredPlaybackPreference();
3322
  renderSavedSessions([]);
3323
  if (!state.sessionId) {
3324
  clearCurrentSessionState(
 
3350
  });
3351
 
3352
  state.authUser = payload.user;
3353
+ state.userAudioOutputPreference = loadStoredAudioOutputPreference();
3354
+ populateAudioOutputChoices();
3355
+ await applyPreferredAudioOutput();
3356
  elements.authUsernameInput.value = payload.user.username;
3357
  syncAuthUI();
3358
  await refreshSavedSessions();
 
3385
  async function logoutUser() {
3386
  await requestJson("/api/auth/logout", { method: "POST" });
3387
  state.authUser = null;
3388
+ state.userPlaybackPreference = loadStoredPlaybackPreference();
3389
+ state.userAudioOutputPreference = loadStoredAudioOutputPreference();
3390
+ populateAudioOutputChoices();
3391
+ await applyPreferredAudioOutput();
3392
  syncAuthUI();
3393
  renderSavedSessions([]);
3394
  setAccountPanelOpen(false);
 
3411
  }
3412
 
3413
  function selectPlaybackPreference(configuration) {
3414
+ const preferredChoiceValue = resolveAvailablePlaybackPreference(
3415
+ configuration,
3416
+ state.midiAccess ? [...state.midiAccess.outputs.values()] : [],
3417
+ );
3418
  if (!preferredChoiceValue) {
3419
  return false;
3420
  }
 
3428
 
3429
  elements.midiOutputSelect.value = preferredChoiceValue;
3430
  updateSelectedOutput();
3431
+ rememberPlaybackPreference({
3432
+ playback_choice: preferredChoiceValue,
3433
+ playback_choice_name:
3434
+ configuration?.playback_choice_name || playbackChoiceLabel(preferredChoiceValue),
3435
+ });
3436
  return true;
3437
  }
3438
 
3439
+ function pendingPlaybackPreference() {
3440
+ const sessionPreference = state.sessionConfiguration?.playback_choice
3441
+ ? {
3442
+ playback_choice: state.sessionConfiguration.playback_choice,
3443
+ playback_choice_name: state.sessionConfiguration.playback_choice_name || null,
3444
+ }
3445
+ : null;
3446
+ const userRendererId = parsePlaybackChoice(state.userPlaybackPreference?.playback_choice)
3447
+ .rendererId;
3448
+ const sessionRendererId = parsePlaybackChoice(sessionPreference?.playback_choice).rendererId;
3449
+ if (
3450
+ state.userPlaybackPreference &&
3451
+ userRendererId === WEB_MIDI_RENDERER_ID &&
3452
+ sessionRendererId !== WEB_MIDI_RENDERER_ID
3453
+ ) {
3454
+ return state.userPlaybackPreference;
3455
+ }
3456
+ return state.sessionConfiguration?.playback_choice
3457
+ ? sessionPreference
3458
+ : state.userPlaybackPreference;
3459
+ }
3460
+
3461
+ function selectedPlaybackRestoresPendingPreference() {
3462
+ const preference = pendingPlaybackPreference();
3463
+ if (!preference?.playback_choice) {
3464
+ return false;
3465
+ }
3466
+ const selectedChoiceValue = selectedPlaybackChoiceValue();
3467
+ if (selectedChoiceValue === preference.playback_choice) {
3468
+ return true;
3469
+ }
3470
+ const selectedChoice = parsePlaybackChoice(selectedChoiceValue);
3471
+ const preferredChoice = parsePlaybackChoice(preference.playback_choice);
3472
+ return (
3473
+ selectedChoice.rendererId === WEB_MIDI_RENDERER_ID &&
3474
+ preferredChoice.rendererId === WEB_MIDI_RENDERER_ID &&
3475
+ normalizedDeviceName(playbackChoiceLabel(selectedChoiceValue)) ===
3476
+ normalizedDeviceName(preference.playback_choice_name)
3477
+ );
3478
+ }
3479
+
3480
+ function normalizedDeviceName(value) {
3481
+ return String(value || "")
3482
+ .trim()
3483
+ .replace(/\s+/g, " ")
3484
+ .toLowerCase();
3485
+ }
3486
+
3487
+ function resolveAvailablePlaybackPreference(preference, outputs = []) {
3488
+ const preferredChoiceValue = preference?.playback_choice || null;
3489
+ if (!preferredChoiceValue) {
3490
+ return null;
3491
+ }
3492
+
3493
+ const availableChoices = new Set([
3494
+ ...localPlaybackRenderers.map((renderer) => encodePlaybackChoice(renderer.id)),
3495
+ ...outputs.map((output) =>
3496
+ encodePlaybackChoice(WEB_MIDI_RENDERER_ID, output.id),
3497
+ ),
3498
+ ]);
3499
+ if (availableChoices.has(preferredChoiceValue)) {
3500
+ return preferredChoiceValue;
3501
+ }
3502
+
3503
+ const { rendererId } = parsePlaybackChoice(preferredChoiceValue);
3504
+ if (rendererId !== WEB_MIDI_RENDERER_ID) {
3505
+ return null;
3506
+ }
3507
+
3508
+ const preferredName = normalizedDeviceName(preference.playback_choice_name);
3509
+ if (!preferredName) {
3510
+ return null;
3511
+ }
3512
+ const matchedOutput = outputs.find((output) => {
3513
+ const outputName = normalizedDeviceName(output.name || output.id);
3514
+ return outputName === preferredName;
3515
+ });
3516
+ return matchedOutput
3517
+ ? encodePlaybackChoice(WEB_MIDI_RENDERER_ID, matchedOutput.id)
3518
+ : null;
3519
+ }
3520
+
3521
  async function selectMidiInputPreference(configuration) {
3522
  const preferredInputId = configuration?.midi_input_id;
3523
  if (!preferredInputId) {
 
3582
  });
3583
 
3584
  await useSessionPayload(payload, { owned: Boolean(state.authUser) });
3585
+ const restoredPlaybackPreference = selectPlaybackPreference(state.userPlaybackPreference);
3586
+ await saveSessionPreferences({
3587
+ ...currentInputPreference(),
3588
+ ...(restoredPlaybackPreference || !state.userPlaybackPreference?.playback_choice
3589
+ ? currentPlaybackPreference()
3590
+ : {}),
3591
+ });
3592
  setControlView("perform");
3593
  if (!preservePhraseBuffers) {
3594
  clearPhraseBuffers();
 
4531
  renderPerformanceState();
4532
  const token = ++state.playbackVisualizationToken;
4533
  const startedAtMs = window.performance.now();
4534
+ const playbackDurationSeconds = Math.max(
4535
  0.001,
4536
  ...notes.map((note) => note.end_seconds || note.start_seconds + note.duration_seconds),
4537
  );
4538
+ const rollDurationSeconds = pianoRollDurationSeconds(notes);
4539
+ const safeDurationMs = Math.max(1, Number(durationMs) || playbackDurationSeconds * 1000);
4540
  state.playbackVisualizationDisplayEndsAtMs = startedAtMs + safeDurationMs;
4541
  elements.outputPlayhead.hidden = rollKind !== "output";
4542
 
 
4546
  }
4547
  const elapsedMs = Math.max(0, window.performance.now() - startedAtMs);
4548
  const progressRatio = Math.min(1, elapsedMs / safeDurationMs);
4549
+ const playbackSeconds = progressRatio * playbackDurationSeconds;
4550
+ const rollProgressRatio = Math.min(1, playbackSeconds / rollDurationSeconds);
4551
  if (rollKind === "output") {
4552
  elements.outputPlayhead.hidden = false;
4553
  elements.outputPlayhead.style.transform = `translateX(${Math.round(
4554
+ rollProgressRatio * outputPlayheadMaxX(),
4555
  )}px)`;
4556
  }
4557
  drawPianoRoll(
 
4560
  accent,
4561
  emptyLabel,
4562
  {
4563
+ progressRatio: rollProgressRatio,
4564
+ playbackSeconds,
4565
  },
4566
  );
4567
  if (progressRatio >= 1) {
 
4992
  function populatePlaybackChoices() {
4993
  const outputs = state.midiAccess ? [...state.midiAccess.outputs.values()] : [];
4994
  const previousChoiceValue = selectedPlaybackChoiceValue();
4995
+ const preferredChoiceValue = resolveAvailablePlaybackPreference(
4996
+ pendingPlaybackPreference(),
4997
+ outputs,
4998
+ );
4999
  const availableChoices = new Set([
5000
  ...localPlaybackRenderers.map((renderer) => encodePlaybackChoice(renderer.id)),
5001
  ...outputs.map((output) =>
 
5156
  }
5157
 
5158
  state.midiAccess = await navigator.requestMIDIAccess({ sysex: false });
5159
+ state.midiAccess.onstatechange = async () => {
5160
+ try {
5161
+ await populateMidiSelectors();
5162
+ if (selectedPlaybackRestoresPendingPreference()) {
5163
+ await saveSessionPreferences(currentPlaybackPreference());
5164
+ }
5165
+ } catch (error) {
5166
+ setPhraseMessage(error.message, true);
5167
+ }
5168
  };
5169
  await populateMidiSelectors();
5170
+ if (selectedPlaybackRestoresPendingPreference()) {
5171
+ await saveSessionPreferences(currentPlaybackPreference());
5172
+ }
5173
  if (!state.activeInputId) {
5174
  setMidiStatus("Connected / choose input");
5175
  }
 
5607
  }
5608
  });
5609
 
5610
+ elements.audioOutputSelect.addEventListener("change", async () => {
5611
+ const selectedDeviceId = elements.audioOutputSelect.value || "default";
5612
+ const device = state.audioOutputDevices.find(
5613
+ (candidate) => candidate.deviceId === selectedDeviceId,
5614
+ );
5615
+ rememberAudioOutputPreference({
5616
+ device_id: selectedDeviceId,
5617
+ device_name: device?.label || "System default",
5618
+ });
5619
+ try {
5620
+ await applyPreferredAudioOutput();
5621
+ setPhraseMessage(
5622
+ `Sound output set to ${device?.label || "System default"}.`,
5623
+ );
5624
+ } catch (error) {
5625
+ setPhraseMessage(error.message, true);
5626
+ }
5627
+ });
5628
+
5629
  faustClavierControls.forEach((control) => {
5630
  control.input.addEventListener("input", () => {
5631
  faustClavierRenderer.setControlValue(control.key, control.input.value);
 
5774
 
5775
  async function initialize() {
5776
  bindEvents();
5777
+ state.userAudioOutputPreference = loadStoredAudioOutputPreference();
5778
  try {
5779
  await initializeCustomFaustSource();
5780
  } catch (error) {
 
5786
  renderSavedSessions([]);
5787
  clearCurrentSessionState();
5788
  setControlView("perform");
5789
+ try {
5790
+ await refreshAudioOutputDevices();
5791
+ } catch {
5792
+ populateAudioOutputChoices();
5793
+ }
5794
+ if (navigator.mediaDevices?.addEventListener) {
5795
+ navigator.mediaDevices.addEventListener("devicechange", () => {
5796
+ void refreshAudioOutputDevices().catch(() => populateAudioOutputChoices());
5797
+ });
5798
+ }
5799
  setSelectedInputName("No MIDI input selected");
5800
  updateSelectedOutput();
5801
  syncFaustRendererPanel();
frontend/index.html CHANGED
@@ -533,6 +533,12 @@
533
  <option>Browser Triangle</option>
534
  </select>
535
  </div>
 
 
 
 
 
 
536
  <div class="playback-status-row">
537
  <span class="playback-status-chip">
538
  <span>Health</span>
@@ -1061,6 +1067,6 @@
1061
  </section>
1062
  </main>
1063
 
1064
- <script type="module" src="/assets/app.js?v=20260425-continuator-package"></script>
1065
  </body>
1066
  </html>
 
533
  <option>Browser Triangle</option>
534
  </select>
535
  </div>
536
+ <div class="route-picker">
537
+ <label class="route-picker-label" for="audio-output-select">Sound Output</label>
538
+ <select id="audio-output-select" disabled>
539
+ <option>System default</option>
540
+ </select>
541
+ </div>
542
  <div class="playback-status-row">
543
  <span class="playback-status-chip">
544
  <span>Health</span>
 
1067
  </section>
1068
  </main>
1069
 
1070
+ <script type="module" src="/assets/app.js?v=20260426-audio-output"></script>
1071
  </body>
1072
  </html>