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:
joren
2026-04-06 02:09:44 +02:00
parent 8b7ad6e9a8
commit 9231c10429
12 changed files with 1105 additions and 14 deletions

View File

@@ -15,6 +15,7 @@ add_executable(pwweb
src/main.cpp src/main.cpp
src/graph_engine.cpp src/graph_engine.cpp
src/web_server.cpp src/web_server.cpp
src/midi_mapper.cpp
) )
target_include_directories(pwweb PRIVATE target_include_directories(pwweb PRIVATE

View File

@@ -17,6 +17,7 @@
getQuantum, setQuantum, getQuantum, setQuantum,
} from '../lib/stores'; } from '../lib/stores';
import type { Node, Port, Link } from '../lib/types'; import type { Node, Port, Link } from '../lib/types';
import MidiMappingPanel from './MidiMappingPanel.svelte';
// Viewport // Viewport
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 }); let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
@@ -44,6 +45,7 @@
let showMergeDialog = $state(false); let showMergeDialog = $state(false);
let showProfileDialog = $state(false); let showProfileDialog = $state(false);
let showRuleDialog = $state(false); let showRuleDialog = $state(false);
let showMidiPanel = $state(false);
let showVirtualMenu = $state(false); let showVirtualMenu = $state(false);
let splitNodes = $state(false); let splitNodes = $state(false);
let showNetworkDialog = $state<{ type: string } | null>(null); 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 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 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={() => { 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={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; showMidiPanel = 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={() => { 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> <span class="sep"></span>
<label class="quantum-label">Buffer: <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); } }}> <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>
</div> </div>
{/if} {/if}
{#if showMidiPanel}
<MidiMappingPanel onClose={() => { showMidiPanel = false; }} />
{/if}
</div> </div>
<style> <style>

View 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>

View File

@@ -1,5 +1,5 @@
import { writable, derived } from 'svelte/store'; 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'; import { subscribe, connectPorts, disconnectPorts } from './ws';
// Raw graph stores // Raw graph stores
@@ -338,6 +338,7 @@ export async function saveProfile(name: string) {
volumes, volumes,
mutes, mutes,
virtual_nodes: [...pb.virtual_nodes], virtual_nodes: [...pb.virtual_nodes],
midi_mappings: [...(pb.profiles[name]?.midi_mappings ?? [])],
}; };
patchbay.update(pb => ({ patchbay.update(pb => ({
@@ -399,6 +400,10 @@ export async function loadProfile(name: string) {
applyPatchbay(pb); applyPatchbay(pb);
applyProfileVolumes(profile); applyProfileVolumes(profile);
// Restore MIDI mappings for this profile
await saveMidiMappings(profile.midi_mappings ?? []);
savePatchbayState(); 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 }; export { connectPorts, disconnectPorts };

View File

@@ -74,6 +74,17 @@ export interface ConnectionRule {
pinned?: boolean; 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 { export interface PatchbayProfile {
name: string; name: string;
connections: ConnectionRule[]; connections: ConnectionRule[];
@@ -82,6 +93,7 @@ export interface PatchbayProfile {
volumes?: Record<string, number>; // PW node name → volume (0..1) volumes?: Record<string, number>; // PW node name → volume (0..1)
mutes?: Record<string, boolean>; // PW node name → mute state mutes?: Record<string, boolean>; // PW node name → mute state
virtual_nodes?: VirtualNodeDef[]; // user-created virtual devices virtual_nodes?: VirtualNodeDef[]; // user-created virtual devices
midi_mappings?: MidiMapping[]; // MIDI controller → node parameter bindings
} }
export interface PatchbayState { export interface PatchbayState {

View File

@@ -1,9 +1,12 @@
import type { GraphMessage } from './types'; import type { GraphMessage } from './types';
type GraphListener = (graph: GraphMessage) => void; 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 es: EventSource | null = null;
let listeners: GraphListener[] = []; let listeners: GraphListener[] = [];
let midiLearnListeners: MidiLearnListener[] = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function connect() { 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 = () => { es.onerror = () => {
console.log('[sse] disconnected, reconnecting in 2s...'); console.log('[sse] disconnected, reconnecting in 2s...');
es?.close(); es?.close();
@@ -48,7 +58,7 @@ export function subscribe(fn: GraphListener): () => void {
connect(); connect();
return () => { return () => {
listeners = listeners.filter(l => l !== fn); listeners = listeners.filter(l => l !== fn);
if (listeners.length === 0 && es) { if (listeners.length === 0 && midiLearnListeners.length === 0 && es) {
es.close(); es.close();
es = null; es = null;
if (reconnectTimer) { 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) { async function postCommand(endpoint: string, outputPortId: number, inputPortId: number) {
try { try {
await fetch(endpoint, { await fetch(endpoint, {

View File

@@ -617,7 +617,7 @@ GraphEngine::Object::~Object() {
// ============================================================================ // ============================================================================
GraphEngine::GraphEngine() GraphEngine::GraphEngine()
: m_on_change(nullptr), m_running(false) : m_running(false)
{ {
m_audio_type = hashType(DEFAULT_AUDIO_TYPE); m_audio_type = hashType(DEFAULT_AUDIO_TYPE);
m_midi_type = hashType(DEFAULT_MIDI_TYPE); m_midi_type = hashType(DEFAULT_MIDI_TYPE);
@@ -733,17 +733,23 @@ void GraphEngine::close() {
void GraphEngine::setOnChange(ChangeCallback cb) { void GraphEngine::setOnChange(ChangeCallback cb) {
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
m_on_change = std::move(cb); m_on_change_cbs.clear();
if (cb) m_on_change_cbs.push_back(std::move(cb));
}
void GraphEngine::addOnChange(ChangeCallback cb) {
std::lock_guard<std::mutex> lock(m_mutex);
if (cb) m_on_change_cbs.push_back(std::move(cb));
} }
void GraphEngine::notifyChanged() { void GraphEngine::notifyChanged() {
// Called from PipeWire thread — invoke callback outside lock // Called from PipeWire thread — copy callback list then invoke outside lock
ChangeCallback cb; std::vector<ChangeCallback> cbs;
{ {
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
cb = m_on_change; cbs = m_on_change_cbs;
} }
if (cb) cb(); for (auto &cb : cbs) cb();
} }
// ============================================================================ // ============================================================================

View File

@@ -5,8 +5,8 @@
#include <mutex> #include <mutex>
#include <vector> #include <vector>
#include <unordered_map> #include <unordered_map>
#include <thread>
#include <atomic> #include <atomic>
#include <thread>
#include <pipewire/utils.h> // pw_thread_loop, etc. #include <pipewire/utils.h> // pw_thread_loop, etc.
#include <spa/utils/list.h> // spa_list #include <spa/utils/list.h> // spa_list
@@ -29,8 +29,9 @@ public:
bool open(); bool open();
void close(); void close();
// Set callback invoked when graph changes // Set/add callbacks invoked when graph changes
void setOnChange(ChangeCallback cb); void setOnChange(ChangeCallback cb);
void addOnChange(ChangeCallback cb);
// Thread-safe snapshot of the current graph state // Thread-safe snapshot of the current graph state
struct Snapshot { struct Snapshot {
@@ -120,7 +121,7 @@ private:
std::unordered_map<uint32_t, Object*> m_objects_by_id; std::unordered_map<uint32_t, Object*> m_objects_by_id;
std::vector<Object*> m_objects; std::vector<Object*> m_objects;
ChangeCallback m_on_change; std::vector<ChangeCallback> m_on_change_cbs;
std::atomic<bool> m_running; std::atomic<bool> m_running;
// Port type hashes // Port type hashes

336
src/midi_mapper.cpp Normal file
View File

@@ -0,0 +1,336 @@
#include "midi_mapper.h"
#include <pipewire/pipewire.h>
#include <spa/pod/builder.h>
#include <spa/pod/iter.h>
#include <spa/pod/pod.h>
#include <spa/control/control.h>
#include <spa/param/format.h>
#include <algorithm>
#include <cstring>
#include <cstdio>
using namespace pwgraph;
// ============================================================================
// PipeWire stream callbacks
// ============================================================================
static void on_midi_process(void *userdata) {
auto *ms = static_cast<MidiMapper::MidiStream *>(userdata);
if (!ms || !ms->stream || !ms->mapper) return;
struct pw_buffer *pw_buf = pw_stream_dequeue_buffer(ms->stream);
if (!pw_buf) return;
struct spa_buffer *buf = pw_buf->buffer;
if (!buf || buf->n_datas == 0 || !buf->datas[0].data) {
pw_stream_queue_buffer(ms->stream, pw_buf);
return;
}
struct spa_data *d = &buf->datas[0];
uint32_t offset = d->chunk ? d->chunk->offset : 0;
uint32_t size = d->chunk ? d->chunk->size : 0;
if (size == 0) {
pw_stream_queue_buffer(ms->stream, pw_buf);
return;
}
void *data = static_cast<uint8_t *>(d->data) + offset;
auto *pod = static_cast<struct spa_pod *>(data);
if (spa_pod_is_sequence(pod)) {
struct spa_pod_control *c;
SPA_POD_SEQUENCE_FOREACH(reinterpret_cast<struct spa_pod_sequence *>(pod), c) {
if (c->type != SPA_CONTROL_Midi) continue;
auto *midi = static_cast<uint8_t *>(SPA_POD_BODY(&c->value));
uint32_t msize = SPA_POD_BODY_SIZE(&c->value);
if (msize < 2) continue;
uint8_t status = midi[0] & 0xF0u;
uint8_t channel = midi[0] & 0x0Fu;
uint8_t data1 = midi[1];
uint8_t data2 = msize >= 3 ? midi[2] : 0;
// Only pass CC (0xB0) and Note On/Off (0x90/0x80)
if (status != 0x90u && status != 0x80u && status != 0xB0u) continue;
ms->mapper->pushEvent(ms->node_name, channel, status, data1, data2);
}
}
pw_stream_queue_buffer(ms->stream, pw_buf);
}
static const struct pw_stream_events s_midi_stream_events = {
PW_VERSION_STREAM_EVENTS,
.process = on_midi_process,
};
// ============================================================================
// MidiMapper
// ============================================================================
MidiMapper::MidiMapper(GraphEngine &engine, BroadcastFn broadcast_fn)
: m_engine(engine), m_broadcast_fn(std::move(broadcast_fn))
{
m_worker_thread = std::thread([this]() { workerLoop(); });
}
MidiMapper::~MidiMapper() {
{
std::lock_guard<std::mutex> lock(m_queue_mutex);
m_worker_stop = true;
m_queue_cv.notify_all();
}
if (m_worker_thread.joinable())
m_worker_thread.join();
destroyAllStreams();
}
void MidiMapper::setMappings(std::vector<MidiMapping> mappings) {
std::lock_guard<std::mutex> lock(m_mappings_mutex);
m_mappings = std::move(mappings);
}
std::vector<MidiMapping> MidiMapper::getMappings() const {
std::lock_guard<std::mutex> lock(m_mappings_mutex);
return m_mappings;
}
void MidiMapper::startLearn() {
m_learning.store(true);
}
void MidiMapper::stopLearn() {
m_learning.store(false);
}
// ============================================================================
// Stream management (must be called from non-PW thread)
// ============================================================================
void MidiMapper::refresh() {
auto snap = m_engine.snapshot();
auto &pw = m_engine.pwData();
// Identify MIDI source nodes (Output or Duplex mode, Midi type)
std::vector<std::pair<uint32_t, std::string>> midi_sources;
for (auto &n : snap.nodes) {
if (!n.ready) continue;
bool is_midi = (n.node_type & NodeType::Midi) != NodeType::None;
bool is_source = (n.mode & PortMode::Output) != PortMode::None;
if (is_midi && is_source)
midi_sources.emplace_back(n.id, n.name);
}
pw_thread_loop_lock(pw.loop);
// Create streams for new nodes
for (auto &[id, name] : midi_sources) {
std::lock_guard<std::mutex> lock(m_streams_mutex);
if (m_streams.find(id) == m_streams.end())
createStream(id, name);
}
// Destroy streams for nodes no longer present
std::vector<uint32_t> to_remove;
{
std::lock_guard<std::mutex> lock(m_streams_mutex);
for (auto &[id, _] : m_streams) {
bool found = false;
for (auto &[sid, _2] : midi_sources) {
if (sid == id) { found = true; break; }
}
if (!found) to_remove.push_back(id);
}
}
for (uint32_t id : to_remove)
destroyStream(id);
pw_thread_loop_unlock(pw.loop);
}
void MidiMapper::createStream(uint32_t node_id, const std::string &name) {
// Called with PW loop locked
auto &pw = m_engine.pwData();
auto *ms = new MidiStream();
ms->node_id = node_id;
ms->node_name = name;
ms->mapper = this;
spa_zero(ms->listener);
struct pw_properties *props = pw_properties_new(
PW_KEY_MEDIA_TYPE, "Midi",
PW_KEY_MEDIA_CATEGORY, "Capture",
PW_KEY_APP_NAME, "pwweb",
PW_KEY_NODE_NAME, "pwweb-midi-in",
PW_KEY_TARGET_OBJECT, name.c_str(),
nullptr);
ms->stream = pw_stream_new(pw.core, "pwweb-midi-in", props);
pw_properties_free(props);
if (!ms->stream) {
delete ms;
return;
}
pw_stream_add_listener(ms->stream, &ms->listener,
&s_midi_stream_events, ms);
uint8_t buf[256];
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
const struct spa_pod *params[1];
params[0] = static_cast<const struct spa_pod *>(spa_pod_builder_add_object(&b,
SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat,
SPA_FORMAT_mediaType, SPA_POD_Id(SPA_MEDIA_TYPE_application),
SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_control)));
pw_stream_connect(ms->stream,
PW_DIRECTION_INPUT,
PW_ID_ANY,
static_cast<pw_stream_flags>(PW_STREAM_FLAG_AUTOCONNECT |
PW_STREAM_FLAG_MAP_BUFFERS),
params, 1);
{
std::lock_guard<std::mutex> lock(m_streams_mutex);
m_streams[node_id] = ms;
}
fprintf(stderr, "pwweb: MIDI stream created for node %u (%s)\n", node_id, name.c_str());
}
void MidiMapper::destroyStream(uint32_t node_id) {
// Called with PW loop locked
MidiStream *ms = nullptr;
{
std::lock_guard<std::mutex> lock(m_streams_mutex);
auto it = m_streams.find(node_id);
if (it == m_streams.end()) return;
ms = it->second;
m_streams.erase(it);
}
if (ms->stream) {
spa_hook_remove(&ms->listener);
pw_stream_destroy(ms->stream);
ms->stream = nullptr;
}
delete ms;
fprintf(stderr, "pwweb: MIDI stream destroyed for node %u\n", node_id);
}
void MidiMapper::destroyAllStreams() {
auto &pw = m_engine.pwData();
if (!pw.loop) return;
pw_thread_loop_lock(pw.loop);
std::vector<uint32_t> ids;
{
std::lock_guard<std::mutex> lock(m_streams_mutex);
for (auto &[id, _] : m_streams)
ids.push_back(id);
}
for (uint32_t id : ids)
destroyStream(id);
pw_thread_loop_unlock(pw.loop);
}
// ============================================================================
// Event queue (called from PW process callback — no locks beyond mutex)
// ============================================================================
void MidiMapper::pushEvent(const std::string &device, uint8_t channel,
uint8_t status, uint8_t data1, uint8_t data2) {
std::lock_guard<std::mutex> lock(m_queue_mutex);
m_event_queue.push({device, channel, status, data1, data2});
m_queue_cv.notify_one();
}
// ============================================================================
// Worker thread
// ============================================================================
void MidiMapper::workerLoop() {
while (true) {
MidiEvent ev;
{
std::unique_lock<std::mutex> lock(m_queue_mutex);
m_queue_cv.wait(lock, [this] {
return !m_event_queue.empty() || m_worker_stop;
});
if (m_worker_stop && m_event_queue.empty()) break;
ev = m_event_queue.front();
m_event_queue.pop();
}
handleEvent(ev);
}
}
void MidiMapper::handleEvent(const MidiEvent &ev) {
bool is_note = (ev.status == 0x90u || ev.status == 0x80u);
bool is_cc = (ev.status == 0xB0u);
// Learn mode: broadcast the first event and stop learning
if (m_learning.exchange(false)) {
if (m_broadcast_fn)
m_broadcast_fn(ev.device, ev.channel, ev.data1, is_note);
return;
}
// Apply mappings
std::vector<MidiMapping> mappings;
{
std::lock_guard<std::mutex> lock(m_mappings_mutex);
mappings = m_mappings;
}
for (auto &m : mappings) {
// Check device
if (!m.device.empty() && m.device != ev.device) continue;
// Check channel
if (m.channel != 0xFFu && m.channel != ev.channel) continue;
if (m.param == "volume" && is_cc && m.cc == ev.data1) {
// Scale CC value (0-127) to volume range
float t = ev.data2 / 127.0f;
float vol = m.min_val + t * (m.max_val - m.min_val);
if (vol < 0.0f) vol = 0.0f;
if (vol > 1.5f) vol = 1.5f;
// Find target node ID and apply
auto snap = m_engine.snapshot();
for (auto &n : snap.nodes) {
if (n.name == m.target_node) {
m_engine.setNodeVolume(n.id, vol);
break;
}
}
} else if (m.param == "mute") {
bool trigger = false;
if (is_cc && m.cc == ev.data1) {
// Toggle on CC value > 63 (or any non-zero for simple toggle)
trigger = (ev.data2 > 0);
} else if (is_note && m.cc == ev.data1 && ev.status == 0x90u && ev.data2 > 0) {
trigger = true;
}
if (!trigger) continue;
auto snap = m_engine.snapshot();
for (auto &n : snap.nodes) {
if (n.name == m.target_node) {
m_engine.setNodeMute(n.id, !n.mute);
break;
}
}
}
}
}

96
src/midi_mapper.h Normal file
View File

@@ -0,0 +1,96 @@
#pragma once
#include "graph_engine.h"
#include <string>
#include <vector>
#include <unordered_map>
#include <functional>
#include <mutex>
#include <atomic>
#include <thread>
#include <queue>
#include <condition_variable>
#include <cstdint>
struct pw_stream;
struct spa_hook;
namespace pwgraph {
struct MidiMapping {
std::string device; // MIDI source node name (empty = any device)
uint8_t channel; // 0-15; 0xFF = any channel
uint8_t cc; // CC number 0-127
bool is_note; // true = note on/off controls mute toggle
std::string target_node; // PW node name
std::string param; // "volume" or "mute"
float min_val; // CC 0 → this value (default 0.0)
float max_val; // CC 127 → this value (default 1.0)
};
class MidiMapper {
public:
// Called when a MIDI learn event is captured
using BroadcastFn = std::function<void(
const std::string &device, uint8_t channel, uint8_t cc, bool is_note)>;
MidiMapper(GraphEngine &engine, BroadcastFn broadcast_fn);
~MidiMapper();
void setMappings(std::vector<MidiMapping> mappings);
std::vector<MidiMapping> getMappings() const;
// Enter/exit learn mode
void startLearn();
void stopLearn();
bool isLearning() const { return m_learning.load(); }
// Re-sync MIDI streams with current graph nodes.
// Must be called from a non-PW thread (it acquires the PW loop lock).
void refresh();
// Internal: called from pw process callback (non-blocking)
struct MidiStream {
uint32_t node_id = 0;
std::string node_name;
pw_stream *stream = nullptr;
spa_hook listener;
MidiMapper *mapper = nullptr;
};
void pushEvent(const std::string &device, uint8_t channel,
uint8_t status, uint8_t data1, uint8_t data2);
private:
struct MidiEvent {
std::string device;
uint8_t channel, status, data1, data2;
};
GraphEngine &m_engine;
BroadcastFn m_broadcast_fn;
mutable std::mutex m_mappings_mutex;
std::vector<MidiMapping> m_mappings;
mutable std::mutex m_streams_mutex;
std::unordered_map<uint32_t, MidiStream *> m_streams; // keyed by PW node ID
std::atomic<bool> m_learning{false};
// Worker thread
std::mutex m_queue_mutex;
std::condition_variable m_queue_cv;
std::queue<MidiEvent> m_event_queue;
bool m_worker_stop = false;
std::thread m_worker_thread;
void workerLoop();
void handleEvent(const MidiEvent &ev);
void createStream(uint32_t node_id, const std::string &name);
void destroyStream(uint32_t node_id); // must be called with PW loop locked
void destroyAllStreams();
};
} // namespace pwgraph

View File

@@ -174,7 +174,15 @@ std::string WebServer::buildGraphJson() const {
// ============================================================================ // ============================================================================
WebServer::WebServer(GraphEngine &engine, int port) WebServer::WebServer(GraphEngine &engine, int port)
: m_engine(engine), m_port(port), m_running(false) : m_engine(engine),
m_midi_mapper(engine, [this](const std::string &dev, uint8_t ch, uint8_t cc, bool is_note) {
char buf[512];
snprintf(buf, sizeof(buf),
"{\"device\":\"%s\",\"channel\":%d,\"cc\":%d,\"is_note\":%s}",
dev.c_str(), (int)ch, (int)cc, is_note ? "true" : "false");
broadcastSse("midi_learn", buf);
}),
m_port(port), m_running(false)
{ {
} }
@@ -230,6 +238,17 @@ void WebServer::broadcastGraph() {
} }
} }
void WebServer::broadcastSse(const std::string &event, const std::string &data) {
if (!m_running) return;
std::string msg = "event: " + event + "\ndata: " + data + "\n\n";
std::lock_guard<std::mutex> lock(m_sse_mutex);
for (auto it = m_sse_clients.begin(); it != m_sse_clients.end(); ) {
if ((*it)->write(msg.c_str(), msg.size())) ++it;
else it = m_sse_clients.erase(it);
}
}
void WebServer::setupRoutes() { void WebServer::setupRoutes() {
// Serve frontend static files from ./frontend/dist // Serve frontend static files from ./frontend/dist
m_http.set_mount_point("/", "./frontend/dist"); m_http.set_mount_point("/", "./frontend/dist");
@@ -607,6 +626,137 @@ void WebServer::setupRoutes() {
}); });
m_http.Options("/api/quantum", cors_handler); m_http.Options("/api/quantum", cors_handler);
m_http.Options("/api/midi-mappings", cors_handler);
m_http.Options("/api/midi-learn/start", cors_handler);
m_http.Options("/api/midi-learn/stop", cors_handler);
// MIDI devices: GET /api/midi-devices
m_http.Get("/api/midi-devices", [this](const httplib::Request &, httplib::Response &res) {
auto snap = m_engine.snapshot();
std::ostringstream json;
json << "[";
bool first = true;
for (auto &n : snap.nodes) {
if (!n.ready) continue;
bool is_midi = ((uint8_t)n.node_type & (uint8_t)NodeType::Midi) != 0;
bool is_source = ((uint8_t)n.mode & (uint8_t)PortMode::Output) != 0;
if (!is_midi || !is_source) continue;
if (!first) json << ",";
first = false;
json << "{\"id\":" << n.id << ",\"name\":\"" << escapeJson(n.name) << "\"}";
}
json << "]";
res.set_content(json.str(), "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// MIDI mappings: GET /api/midi-mappings
m_http.Get("/api/midi-mappings", [this](const httplib::Request &, httplib::Response &res) {
std::string path = dataDir() + "/midi-mappings.json";
res.set_content(readFile(path), "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// MIDI mappings: PUT /api/midi-mappings (body = full profile patchbay.json mappings blob)
m_http.Put("/api/midi-mappings", [this](const httplib::Request &req, httplib::Response &res) {
std::string path = dataDir() + "/midi-mappings.json";
writeFile(path, req.body);
// Parse and apply mappings
// Simple JSON parser: array of {device, channel, cc, is_note, target_node, param, min, max}
std::vector<MidiMapping> mappings;
const std::string &body = req.body;
size_t pos = 0;
auto extractStr = [&](const std::string &key, size_t from) -> std::string {
size_t p = body.find("\"" + key + "\"", from);
if (p == std::string::npos || p > from + 300) return "";
size_t s = body.find('"', p + key.size() + 2);
if (s == std::string::npos) return "";
size_t e = body.find('"', s + 1);
return e != std::string::npos ? body.substr(s + 1, e - s - 1) : "";
};
auto extractNum = [&](const std::string &key, size_t from) -> int {
size_t p = body.find("\"" + key + "\"", from);
if (p == std::string::npos || p > from + 300) return -1;
size_t s = body.find(':', p + key.size() + 2);
if (s == std::string::npos) return -1;
s++;
while (s < body.size() && body[s] == ' ') s++;
int v = 0;
bool neg = false;
if (s < body.size() && body[s] == '-') { neg = true; s++; }
while (s < body.size() && isdigit((unsigned char)body[s]))
v = v * 10 + (body[s++] - '0');
return neg ? -v : v;
};
auto extractFloat = [&](const std::string &key, size_t from) -> float {
size_t p = body.find("\"" + key + "\"", from);
if (p == std::string::npos || p > from + 300) return -1.0f;
size_t s = body.find(':', p + key.size() + 2);
if (s == std::string::npos) return -1.0f;
s++;
while (s < body.size() && body[s] == ' ') s++;
return strtof(body.c_str() + s, nullptr);
};
auto extractBool = [&](const std::string &key, size_t from) -> bool {
size_t p = body.find("\"" + key + "\"", from);
if (p == std::string::npos || p > from + 300) return false;
size_t s = body.find(':', p + key.size() + 2);
if (s == std::string::npos) return false;
s++;
while (s < body.size() && body[s] == ' ') s++;
return body.compare(s, 4, "true") == 0;
};
while (true) {
pos = body.find('{', pos);
if (pos == std::string::npos) break;
size_t end = body.find('}', pos);
if (end == std::string::npos) break;
std::string target = extractStr("target_node", pos);
std::string param = extractStr("param", pos);
if (target.empty() || param.empty()) { pos = end + 1; continue; }
MidiMapping mm;
mm.device = extractStr("device", pos);
int ch = extractNum("channel", pos);
mm.channel = (ch < 0) ? 0xFFu : (uint8_t)ch;
int cc = extractNum("cc", pos);
mm.cc = (cc < 0) ? 0u : (uint8_t)cc;
mm.is_note = extractBool("is_note", pos);
mm.target_node = target;
mm.param = param;
float mn = extractFloat("min", pos);
float mx = extractFloat("max", pos);
mm.min_val = (mn < 0.0f) ? 0.0f : mn;
mm.max_val = (mx < 0.0f) ? 1.0f : mx;
mappings.push_back(mm);
pos = end + 1;
}
m_midi_mapper.setMappings(mappings);
m_midi_mapper.refresh(); // sync streams with current MIDI nodes
res.set_content("{\"ok\":true}", "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// MIDI learn: POST /api/midi-learn/start
m_http.Post("/api/midi-learn/start", [this](const httplib::Request &, httplib::Response &res) {
m_midi_mapper.refresh(); // ensure streams are up to date
m_midi_mapper.startLearn();
res.set_content("{\"ok\":true}", "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// MIDI learn: POST /api/midi-learn/stop
m_http.Post("/api/midi-learn/stop", [this](const httplib::Request &, httplib::Response &res) {
m_midi_mapper.stopLearn();
res.set_content("{\"ok\":true}", "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Get current quantum: GET /api/quantum // Get current quantum: GET /api/quantum
m_http.Get("/api/quantum", [](const httplib::Request &, httplib::Response &res) { m_http.Get("/api/quantum", [](const httplib::Request &, httplib::Response &res) {

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "graph_engine.h" #include "graph_engine.h"
#include "midi_mapper.h"
#include <httplib.h> #include <httplib.h>
#include <thread> #include <thread>
#include <mutex> #include <mutex>
@@ -18,6 +19,7 @@ public:
bool start(); bool start();
void stop(); void stop();
void broadcastGraph(); void broadcastGraph();
void broadcastSse(const std::string &event, const std::string &data);
private: private:
void setupRoutes(); void setupRoutes();
@@ -27,6 +29,7 @@ private:
void writeFile(const std::string &path, const std::string &data) const; void writeFile(const std::string &path, const std::string &data) const;
GraphEngine &m_engine; GraphEngine &m_engine;
MidiMapper m_midi_mapper;
int m_port; int m_port;
httplib::Server m_http; httplib::Server m_http;
std::thread m_thread; std::thread m_thread;