feat: MIDI controller mapping (per-profile CC → volume/mute)
- Add MidiMapper class: pw_stream per MIDI source node, worker thread, learn mode via SSE named event - New endpoints: /api/midi-devices, /api/midi-mappings, /api/midi-learn/start/stop - Frontend: MidiMappingPanel with learn mode, per-profile storage - GraphEngine: support multiple onChange callbacks (addOnChange) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
getQuantum, setQuantum,
|
||||
} from '../lib/stores';
|
||||
import type { Node, Port, Link } from '../lib/types';
|
||||
import MidiMappingPanel from './MidiMappingPanel.svelte';
|
||||
|
||||
// Viewport
|
||||
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
|
||||
@@ -44,6 +45,7 @@
|
||||
let showMergeDialog = $state(false);
|
||||
let showProfileDialog = $state(false);
|
||||
let showRuleDialog = $state(false);
|
||||
let showMidiPanel = $state(false);
|
||||
let showVirtualMenu = $state(false);
|
||||
let splitNodes = $state(false);
|
||||
let showNetworkDialog = $state<{ type: string } | null>(null);
|
||||
@@ -481,8 +483,9 @@
|
||||
<button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button>
|
||||
<button class="toggle" class:active={splitNodes} onclick={() => { splitNodes = !splitNodes; }} title="Show input/output as separate nodes">Split</button>
|
||||
<button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button>
|
||||
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button>
|
||||
<button onclick={() => { showVirtualMenu = !showVirtualMenu; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Add virtual device">+ Add</button>
|
||||
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; showMidiPanel = false; }} title="Save/load profiles">Profiles</button>
|
||||
<button onclick={() => { showMidiPanel = !showMidiPanel; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="MIDI controller mappings">MIDI</button>
|
||||
<button onclick={() => { showVirtualMenu = !showVirtualMenu; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; showMidiPanel = false; }} title="Add virtual device">+ Add</button>
|
||||
<span class="sep"></span>
|
||||
<label class="quantum-label">Buffer:
|
||||
<select class="quantum-select" onchange={(e) => { const q = Number((e.target as HTMLSelectElement).value); if (q > 0) { currentQuantum = q; setQuantum(q); } }}>
|
||||
@@ -888,6 +891,10 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showMidiPanel}
|
||||
<MidiMappingPanel onClose={() => { showMidiPanel = false; }} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
408
frontend/src/components/MidiMappingPanel.svelte
Normal file
408
frontend/src/components/MidiMappingPanel.svelte
Normal file
@@ -0,0 +1,408 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { nodes, patchbay, getMidiDevices, saveMidiMappings, getActiveMidiMappings, startMidiLearn, stopMidiLearn } from '../lib/stores';
|
||||
import type { MidiMapping } from '../lib/types';
|
||||
import { subscribeMidiLearn } from '../lib/ws';
|
||||
|
||||
let { onClose } = $props<{ onClose: () => void }>();
|
||||
|
||||
// Current mappings (local copy while panel is open)
|
||||
let mappings = $state<MidiMapping[]>(getActiveMidiMappings());
|
||||
|
||||
// Available MIDI devices
|
||||
let midiDevices = $state<{ id: number; name: string }[]>([]);
|
||||
getMidiDevices().then(d => { midiDevices = d; });
|
||||
|
||||
// Current node list for target selector
|
||||
let nodeList = $derived($nodes.filter(n => n.node_type !== 'other').map(n => n.name));
|
||||
|
||||
// Add mapping form
|
||||
let form = $state<{
|
||||
device: string;
|
||||
channel: number;
|
||||
cc: number;
|
||||
is_note: boolean;
|
||||
target_node: string;
|
||||
param: 'volume' | 'mute';
|
||||
min: number;
|
||||
max: number;
|
||||
}>({
|
||||
device: '',
|
||||
channel: 255,
|
||||
cc: 0,
|
||||
is_note: false,
|
||||
target_node: '',
|
||||
param: 'volume',
|
||||
min: 0,
|
||||
max: 1,
|
||||
});
|
||||
|
||||
let showAddForm = $state(false);
|
||||
let learning = $state(false);
|
||||
let learnStatus = $state('');
|
||||
|
||||
// Subscribe to MIDI learn events
|
||||
const unsubLearn = subscribeMidiLearn((ev) => {
|
||||
if (!learning) return;
|
||||
form.device = ev.device;
|
||||
form.cc = ev.cc;
|
||||
form.is_note = ev.is_note;
|
||||
form.channel = ev.channel;
|
||||
learning = false;
|
||||
learnStatus = `Captured: ${ev.is_note ? 'Note' : 'CC'} ${ev.cc} on CH${ev.channel + 1} from "${ev.device}"`;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubLearn();
|
||||
if (learning) stopMidiLearn();
|
||||
});
|
||||
|
||||
async function doLearn() {
|
||||
learnStatus = 'Listening for MIDI... (wiggle a knob or press a key)';
|
||||
learning = true;
|
||||
await startMidiLearn();
|
||||
}
|
||||
|
||||
function cancelLearn() {
|
||||
learning = false;
|
||||
learnStatus = '';
|
||||
stopMidiLearn();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form = { device: '', channel: 255, cc: 0, is_note: false, target_node: nodeList[0] ?? '', param: 'volume', min: 0, max: 1 };
|
||||
learnStatus = '';
|
||||
learning = false;
|
||||
}
|
||||
|
||||
function addMapping() {
|
||||
if (!form.target_node) return;
|
||||
mappings = [...mappings, { ...form }];
|
||||
showAddForm = false;
|
||||
resetForm();
|
||||
saveMidiMappings(mappings);
|
||||
}
|
||||
|
||||
function removeMapping(i: number) {
|
||||
mappings = mappings.filter((_, idx) => idx !== i);
|
||||
saveMidiMappings(mappings);
|
||||
}
|
||||
|
||||
function ccLabel(m: MidiMapping): string {
|
||||
const ch = m.channel === 255 ? 'Any CH' : `CH${m.channel + 1}`;
|
||||
const kind = m.is_note ? `Note ${m.cc}` : `CC ${m.cc}`;
|
||||
const dev = m.device || 'Any device';
|
||||
return `${dev} · ${ch} · ${kind}`;
|
||||
}
|
||||
|
||||
function paramLabel(m: MidiMapping): string {
|
||||
if (m.param === 'volume') return `Volume (${m.min.toFixed(2)}–${m.max.toFixed(2)})`;
|
||||
return 'Mute toggle';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span>MIDI Mappings</span>
|
||||
<button class="close-btn" onclick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
{#if !$patchbay.active_profile}
|
||||
<div class="notice">Load a profile first to save MIDI mappings.</div>
|
||||
{:else}
|
||||
<div class="profile-label">Profile: <strong>{$patchbay.active_profile}</strong></div>
|
||||
{/if}
|
||||
|
||||
<!-- Mapping list -->
|
||||
{#if mappings.length === 0}
|
||||
<div class="empty">No mappings yet.</div>
|
||||
{:else}
|
||||
<div class="mapping-list">
|
||||
{#each mappings as m, i}
|
||||
<div class="mapping-row">
|
||||
<div class="mapping-info">
|
||||
<span class="mapping-src">{ccLabel(m)}</span>
|
||||
<span class="mapping-arrow">→</span>
|
||||
<span class="mapping-dst">{m.target_node} · {paramLabel(m)}</span>
|
||||
</div>
|
||||
<button class="del-btn" onclick={() => removeMapping(i)}>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add mapping -->
|
||||
{#if !showAddForm}
|
||||
<button class="add-btn" onclick={() => { showAddForm = true; resetForm(); form.target_node = nodeList[0] ?? ''; }}>
|
||||
+ Add Mapping
|
||||
</button>
|
||||
{:else}
|
||||
<div class="add-form">
|
||||
<div class="form-row">
|
||||
<label>Target node</label>
|
||||
<select bind:value={form.target_node}>
|
||||
{#each nodeList as name}
|
||||
<option value={name}>{name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Parameter</label>
|
||||
<select bind:value={form.param}>
|
||||
<option value="volume">Volume</option>
|
||||
<option value="mute">Mute toggle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if form.param === 'volume'}
|
||||
<div class="form-row">
|
||||
<label>Range</label>
|
||||
<input type="number" min="0" max="1.5" step="0.05" bind:value={form.min} style="width:60px" />
|
||||
<span style="margin:0 4px">–</span>
|
||||
<input type="number" min="0" max="1.5" step="0.05" bind:value={form.max} style="width:60px" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-row">
|
||||
<label>MIDI source</label>
|
||||
<div class="learn-row">
|
||||
{#if !learning}
|
||||
<button class="learn-btn" onclick={doLearn}>🎹 Learn...</button>
|
||||
{:else}
|
||||
<button class="learn-btn learning" onclick={cancelLearn}>⏹ Cancel</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if learnStatus}
|
||||
<div class="learn-status">{learnStatus}</div>
|
||||
{/if}
|
||||
|
||||
{#if form.device || learnStatus}
|
||||
<div class="form-row">
|
||||
<label for="form-device">Device</label>
|
||||
<select id="form-device" bind:value={form.device}>
|
||||
<option value="">Any device</option>
|
||||
{#each midiDevices as d}
|
||||
<option value={d.name}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="form-channel">Channel</label>
|
||||
<select id="form-channel" bind:value={form.channel}>
|
||||
<option value={255}>Any</option>
|
||||
{#each Array.from({length:16},(_,i)=>i) as ch}
|
||||
<option value={ch}>CH{ch + 1}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="form-cc">{form.is_note ? 'Note' : 'CC'} #</label>
|
||||
<input id="form-cc" type="number" min="0" max="127" bind:value={form.cc} style="width:70px" />
|
||||
<label for="form-isnote" style="margin-left:8px;width:auto">
|
||||
<input id="form-isnote" type="checkbox" bind:checked={form.is_note} />
|
||||
Note
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="save-btn" disabled={!form.target_node || (!learnStatus && !form.device)} onclick={addMapping}>
|
||||
Save
|
||||
</button>
|
||||
<button class="cancel-btn" onclick={() => { showAddForm = false; cancelLearn(); }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
right: 8px;
|
||||
width: 400px;
|
||||
max-height: calc(100vh - 70px);
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #444466;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 24px #0009;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #2a2a3e;
|
||||
border-bottom: 1px solid #444466;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.close-btn:hover { color: #fff; }
|
||||
.panel-body {
|
||||
padding: 10px 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.notice, .empty {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.profile-label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mapping-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mapping-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #252535;
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.mapping-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mapping-src { color: #7aadff; }
|
||||
.mapping-arrow { color: #666; }
|
||||
.mapping-dst { color: #aaffaa; }
|
||||
.del-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff6666;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.del-btn:hover { color: #ff4444; }
|
||||
.add-btn {
|
||||
background: #2a3a4a;
|
||||
border: 1px solid #4466aa;
|
||||
border-radius: 4px;
|
||||
color: #88aaff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.add-btn:hover { background: #334455; }
|
||||
.add-form {
|
||||
background: #252535;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
.form-row label {
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
color: #aaa;
|
||||
font-size: 11px;
|
||||
}
|
||||
.form-row select, .form-row input[type="number"] {
|
||||
background: #1a1a2a;
|
||||
border: 1px solid #444466;
|
||||
border-radius: 3px;
|
||||
color: #ddd;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.learn-row { display: flex; align-items: center; gap: 6px; }
|
||||
.learn-btn {
|
||||
background: #2a3a4a;
|
||||
border: 1px solid #4466aa;
|
||||
border-radius: 4px;
|
||||
color: #88aaff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.learn-btn.learning {
|
||||
background: #3a2a2a;
|
||||
border-color: #aa4444;
|
||||
color: #ff8888;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
.learn-status {
|
||||
font-size: 11px;
|
||||
color: #88ddaa;
|
||||
padding: 3px 6px;
|
||||
background: #1a2a1a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.save-btn {
|
||||
background: #2a4a3a;
|
||||
border: 1px solid #44aa66;
|
||||
border-radius: 4px;
|
||||
color: #88ffaa;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 4px 14px;
|
||||
}
|
||||
.save-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.save-btn:not(:disabled):hover { background: #335544; }
|
||||
.cancel-btn {
|
||||
background: none;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.cancel-btn:hover { background: #2a2a3a; }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule, VirtualNodeDef } from './types';
|
||||
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule, VirtualNodeDef, MidiMapping } from './types';
|
||||
import { subscribe, connectPorts, disconnectPorts } from './ws';
|
||||
|
||||
// Raw graph stores
|
||||
@@ -338,6 +338,7 @@ export async function saveProfile(name: string) {
|
||||
volumes,
|
||||
mutes,
|
||||
virtual_nodes: [...pb.virtual_nodes],
|
||||
midi_mappings: [...(pb.profiles[name]?.midi_mappings ?? [])],
|
||||
};
|
||||
|
||||
patchbay.update(pb => ({
|
||||
@@ -399,6 +400,10 @@ export async function loadProfile(name: string) {
|
||||
|
||||
applyPatchbay(pb);
|
||||
applyProfileVolumes(profile);
|
||||
|
||||
// Restore MIDI mappings for this profile
|
||||
await saveMidiMappings(profile.midi_mappings ?? []);
|
||||
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
@@ -557,4 +562,52 @@ export async function setQuantum(quantum: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// MIDI mapping API
|
||||
export async function getMidiDevices(): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
const res = await fetch('/api/midi-devices');
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function saveMidiMappings(mappings: MidiMapping[]): Promise<void> {
|
||||
// Also persist to the active profile
|
||||
patchbay.update(pb => {
|
||||
const prof = pb.active_profile ? pb.profiles[pb.active_profile] : null;
|
||||
if (!prof) return pb;
|
||||
return {
|
||||
...pb,
|
||||
profiles: {
|
||||
...pb.profiles,
|
||||
[pb.active_profile]: { ...prof, midi_mappings: mappings },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await fetch('/api/midi-mappings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mappings),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[api] midi-mappings PUT failed:', e);
|
||||
}
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function getActiveMidiMappings(): MidiMapping[] {
|
||||
const pb = get_store_value(patchbay);
|
||||
if (!pb.active_profile || !pb.profiles[pb.active_profile]) return [];
|
||||
return pb.profiles[pb.active_profile].midi_mappings ?? [];
|
||||
}
|
||||
|
||||
export async function startMidiLearn(): Promise<void> {
|
||||
try { await fetch('/api/midi-learn/start', { method: 'POST' }); } catch {}
|
||||
}
|
||||
|
||||
export async function stopMidiLearn(): Promise<void> {
|
||||
try { await fetch('/api/midi-learn/stop', { method: 'POST' }); } catch {}
|
||||
}
|
||||
|
||||
export { connectPorts, disconnectPorts };
|
||||
|
||||
@@ -74,6 +74,17 @@ export interface ConnectionRule {
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface MidiMapping {
|
||||
device: string; // MIDI source node name (empty = any device)
|
||||
channel: number; // 0-15; 255 = any channel
|
||||
cc: number; // CC number 0-127 (or note number if is_note)
|
||||
is_note: boolean; // true = note on/off controls mute
|
||||
target_node: string; // PW node name
|
||||
param: 'volume' | 'mute';
|
||||
min: number; // CC 0 → this value (for volume)
|
||||
max: number; // CC 127 → this value (for volume)
|
||||
}
|
||||
|
||||
export interface PatchbayProfile {
|
||||
name: string;
|
||||
connections: ConnectionRule[];
|
||||
@@ -82,6 +93,7 @@ export interface PatchbayProfile {
|
||||
volumes?: Record<string, number>; // PW node name → volume (0..1)
|
||||
mutes?: Record<string, boolean>; // PW node name → mute state
|
||||
virtual_nodes?: VirtualNodeDef[]; // user-created virtual devices
|
||||
midi_mappings?: MidiMapping[]; // MIDI controller → node parameter bindings
|
||||
}
|
||||
|
||||
export interface PatchbayState {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { GraphMessage } from './types';
|
||||
|
||||
type GraphListener = (graph: GraphMessage) => void;
|
||||
export type MidiLearnEvent = { device: string; channel: number; cc: number; is_note: boolean };
|
||||
type MidiLearnListener = (ev: MidiLearnEvent) => void;
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let listeners: GraphListener[] = [];
|
||||
let midiLearnListeners: MidiLearnListener[] = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function connect() {
|
||||
@@ -30,6 +33,13 @@ function connect() {
|
||||
}
|
||||
};
|
||||
|
||||
es.addEventListener('midi_learn', (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as MidiLearnEvent;
|
||||
for (const fn of midiLearnListeners) fn(data);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
console.log('[sse] disconnected, reconnecting in 2s...');
|
||||
es?.close();
|
||||
@@ -48,7 +58,7 @@ export function subscribe(fn: GraphListener): () => void {
|
||||
connect();
|
||||
return () => {
|
||||
listeners = listeners.filter(l => l !== fn);
|
||||
if (listeners.length === 0 && es) {
|
||||
if (listeners.length === 0 && midiLearnListeners.length === 0 && es) {
|
||||
es.close();
|
||||
es = null;
|
||||
if (reconnectTimer) {
|
||||
@@ -59,6 +69,14 @@ export function subscribe(fn: GraphListener): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeMidiLearn(fn: MidiLearnListener): () => void {
|
||||
midiLearnListeners.push(fn);
|
||||
connect();
|
||||
return () => {
|
||||
midiLearnListeners = midiLearnListeners.filter(l => l !== fn);
|
||||
};
|
||||
}
|
||||
|
||||
async function postCommand(endpoint: string, outputPortId: number, inputPortId: number) {
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
|
||||
Reference in New Issue
Block a user