#include "midi_mapper.h" #include #include #include #include #include #include #include #include #include using namespace pwgraph; // ============================================================================ // PipeWire stream callbacks // ============================================================================ static void on_midi_process(void *userdata) { auto *ms = static_cast(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(d->data) + offset; auto *pod = static_cast(data); if (spa_pod_is_sequence(pod)) { struct spa_pod_control *c; SPA_POD_SEQUENCE_FOREACH(reinterpret_cast(pod), c) { if (c->type != SPA_CONTROL_Midi) continue; auto *midi = static_cast(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 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 mappings) { std::lock_guard lock(m_mappings_mutex); m_mappings = std::move(mappings); } std::vector MidiMapper::getMappings() const { std::lock_guard 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> 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 lock(m_streams_mutex); if (m_streams.find(id) == m_streams.end()) createStream(id, name); } // Destroy streams for nodes no longer present std::vector to_remove; { std::lock_guard 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(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_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS), params, 1); { std::lock_guard 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 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 ids; { std::lock_guard 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 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 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 mappings; { std::lock_guard 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; } } } } }