Midi/Bridge nodes have media.class = "Midi/Bridge" with no Source/Sink/ Input/Output keyword, so mode stays PortMode::None. mode2 is derived from actual port directions and correctly reflects Output for bridge nodes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
341 lines
11 KiB
C++
341 lines
11 KiB
C++
#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.
|
|
// Use mode2 (derived from actual port directions) rather than mode (from
|
|
// media.class string) because Midi/Bridge nodes have mode=None but still
|
|
// have real output ports and must be captured.
|
|
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 ||
|
|
(n.mode2 & 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|