Spaces:
Running
Running
Remember MIDI playback output preferences
Browse files- frontend/app.js +404 -16
- 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 =
|
| 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 |
-
|
| 2982 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 4193 |
0.001,
|
| 4194 |
...notes.map((note) => note.end_seconds || note.start_seconds + note.duration_seconds),
|
| 4195 |
);
|
| 4196 |
-
const
|
|
|
|
| 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 |
-
|
| 4210 |
)}px)`;
|
| 4211 |
}
|
| 4212 |
drawPianoRoll(
|
|
@@ -4215,8 +4560,8 @@ function startPlaybackVisualization(
|
|
| 4215 |
accent,
|
| 4216 |
emptyLabel,
|
| 4217 |
{
|
| 4218 |
-
progressRatio,
|
| 4219 |
-
playbackSeconds
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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>
|