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

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;
}
}
}
}
}