P4: MIDI control surface mapping — faders/buttons over USB MIDI

C++ backend (fully implemented):
- MidiMapper: CC processing, mapping table, JSON persistence, learn mode
- MidiLearnMode: 3-step learn workflow state machine
- MixerEngine::processMidiEvent() wired into AudioHost MIDI pipeline
- MixerApi + PiPedalSocket: all WS handlers (getMidiMappings,
  setMidiLearnMode, setMidiLearnTarget, commitMidiLearn, etc.)

React frontend (new):
- MidiMappingPanel: dialog with learn mode toggle, CC capture polling,
  commit workflow, current mappings list with delete, manual add
- MixerPage: MIDI button in toolbar, learn mode state management
- ChannelStrip + MasterBus: learn mode callbacks on fader/mute/solo touch
This commit is contained in:
2026-06-20 16:14:12 -04:00
parent 3d00299051
commit 5fd5946ff6
8 changed files with 1546 additions and 7 deletions
+415
View File
@@ -0,0 +1,415 @@
// Copyright (c) 2026 Ourpad Network
// See LICENSE file in the project root for full license text.
#include "pch.h"
#include "MidiMapper.hpp"
#include "MixerEngine.hpp"
#include "MixerChannelStrip.hpp"
#include "MixerBus.hpp"
#include "MidiEvent.hpp"
#include "json.hpp"
#include <cmath>
#include <filesystem>
#include <fstream>
#include <sstream>
using namespace pipedal;
// ─── Target type string conversion ──────────────────────────────────────────
MidiTargetType MidiMappingEntry::targetTypeFromString(const std::string& str)
{
if (str == "channelVolume") return MidiTargetType::ChannelVolume;
if (str == "channelPan") return MidiTargetType::ChannelPan;
if (str == "channelMute") return MidiTargetType::ChannelMute;
if (str == "channelSolo") return MidiTargetType::ChannelSolo;
if (str == "busVolume") return MidiTargetType::BusVolume;
if (str == "busMute") return MidiTargetType::BusMute;
if (str == "masterVolume") return MidiTargetType::MasterVolume;
if (str == "masterMute") return MidiTargetType::MasterMute;
return MidiTargetType::ChannelVolume;
}
const char* MidiMappingEntry::targetTypeToString(MidiTargetType type)
{
switch (type) {
case MidiTargetType::ChannelVolume: return "channelVolume";
case MidiTargetType::ChannelPan: return "channelPan";
case MidiTargetType::ChannelMute: return "channelMute";
case MidiTargetType::ChannelSolo: return "channelSolo";
case MidiTargetType::BusVolume: return "busVolume";
case MidiTargetType::BusMute: return "busMute";
case MidiTargetType::MasterVolume: return "masterVolume";
case MidiTargetType::MasterMute: return "masterMute";
}
return "channelVolume";
}
// ─── MidiMapper ─────────────────────────────────────────────────────────────
MidiMapper::MidiMapper()
{
}
MidiMapper::~MidiMapper()
{
}
bool MidiMapper::processEvent(const MidiEvent& event)
{
if (!mixerEngine_) return false;
// Only process MIDI CC messages (0xB0)
if (event.size < 3) return false;
uint8_t command = event.buffer[0] & 0xF0;
if (command != 0xB0) return false;
int midiChannel = static_cast<int>(event.buffer[0] & 0x0F);
int ccNumber = static_cast<int>(event.buffer[1]);
uint8_t ccValue = event.buffer[2];
// ── Learn mode: capture the CC event ──
if (learnMode_) {
std::lock_guard<std::mutex> lock(learnMutex_);
lastLearnedMidiChannel_ = midiChannel;
lastLearnedCcNumber_ = ccNumber;
}
// ── Snapshot the current mapping table ──
std::vector<MidiMappingEntry> mappingsSnapshot;
{
std::lock_guard<std::mutex> lock(mappingsMutex_);
mappingsSnapshot = mappings_;
}
// ── Apply matching mappings ──
bool consumed = false;
for (const auto& entry : mappingsSnapshot) {
// Match MIDI channel (-1 = omni)
if (entry.midiChannel >= 0 && entry.midiChannel != midiChannel) {
continue;
}
if (entry.ccNumber != ccNumber) {
continue;
}
applyValue(entry, ccValue);
consumed = true;
}
return consumed;
}
void MidiMapper::applyValue(const MidiMappingEntry& entry, uint8_t ccValue)
{
if (!mixerEngine_) return;
// Map CC 0-127 to parameter range
float range = entry.maxValue - entry.minValue;
float normalized = static_cast<float>(ccValue) / 127.0f;
float mappedValue = entry.minValue + normalized * range;
switch (entry.targetType) {
case MidiTargetType::ChannelVolume: {
auto* ch = mixerEngine_->getChannel(static_cast<int>(entry.targetId));
if (ch) ch->setVolume(mappedValue);
break;
}
case MidiTargetType::ChannelPan: {
auto* ch = mixerEngine_->getChannel(static_cast<int>(entry.targetId));
if (ch) ch->setPan(mappedValue);
break;
}
case MidiTargetType::ChannelMute: {
auto* ch = mixerEngine_->getChannel(static_cast<int>(entry.targetId));
if (ch) ch->setMute(mappedValue >= 0.5f);
break;
}
case MidiTargetType::ChannelSolo: {
auto* ch = mixerEngine_->getChannel(static_cast<int>(entry.targetId));
if (ch) ch->setSolo(mappedValue >= 0.5f);
break;
}
case MidiTargetType::BusVolume: {
auto* bus = mixerEngine_->getBus(entry.targetId);
if (bus) bus->setVolume(mappedValue);
break;
}
case MidiTargetType::BusMute: {
auto* bus = mixerEngine_->getBus(entry.targetId);
if (bus) bus->setMute(mappedValue >= 0.5f);
break;
}
case MidiTargetType::MasterVolume: {
auto* bus = mixerEngine_->masterBus();
if (bus) bus->setVolume(mappedValue);
break;
}
case MidiTargetType::MasterMute: {
auto* bus = mixerEngine_->masterBus();
if (bus) bus->setMute(mappedValue >= 0.5f);
break;
}
}
}
// ─── Mapping table management ───────────────────────────────────────────────
void MidiMapper::setMappings(const std::vector<MidiMappingEntry>& mappings)
{
std::lock_guard<std::mutex> lock(mappingsMutex_);
mappings_ = mappings;
}
void MidiMapper::addMapping(const MidiMappingEntry& entry)
{
std::lock_guard<std::mutex> lock(mappingsMutex_);
mappings_.push_back(entry);
}
bool MidiMapper::removeMapping(int midiChannel, int ccNumber)
{
std::lock_guard<std::mutex> lock(mappingsMutex_);
auto it = std::remove_if(mappings_.begin(), mappings_.end(),
[midiChannel, ccNumber](const MidiMappingEntry& e) {
return e.midiChannel == midiChannel && e.ccNumber == ccNumber;
});
bool removed = (it != mappings_.end());
mappings_.erase(it, mappings_.end());
return removed;
}
bool MidiMapper::removeMappingByIndex(size_t index)
{
std::lock_guard<std::mutex> lock(mappingsMutex_);
if (index >= mappings_.size()) return false;
mappings_.erase(mappings_.begin() + static_cast<ptrdiff_t>(index));
return true;
}
void MidiMapper::clearMappings()
{
std::lock_guard<std::mutex> lock(mappingsMutex_);
mappings_.clear();
}
std::vector<MidiMappingEntry> MidiMapper::getMappings() const
{
std::lock_guard<std::mutex> lock(mappingsMutex_);
return mappings_;
}
// ─── JSON serialization ─────────────────────────────────────────────────────
std::string MidiMapper::getMappingsJson() const
{
auto mappings = getMappings();
std::stringstream ss;
json_writer writer(ss, false);
writer.start_array();
for (const auto& entry : mappings) {
writer.start_object();
writer.write_member("midiChannel", (int64_t)entry.midiChannel);
writer.write_member("ccNumber", (int64_t)entry.ccNumber);
writer.write_member("targetType", MidiMappingEntry::targetTypeToString(entry.targetType));
writer.write_member("targetId", entry.targetId);
writer.write_member("minValue", (double)entry.minValue);
writer.write_member("maxValue", (double)entry.maxValue);
writer.end_object();
}
writer.end_array();
return ss.str();
}
void MidiMapper::setMappingsFromJson(const std::string& json)
{
std::vector<MidiMappingEntry> entries;
std::stringstream ss(json);
json_reader reader(ss);
// Parse array: [ ... ]
reader.consume('[');
while (reader.peek() != ']') {
MidiMappingEntry entry;
std::string targetTypeStr;
// Parse object: { "key": value, ... }
reader.consume('{');
while (reader.peek() != '}') {
std::string key;
reader.read(&key);
reader.consume(':');
if (key == "midiChannel") {
int64_t v; reader.read(&v); entry.midiChannel = (int)v;
} else if (key == "ccNumber") {
int64_t v; reader.read(&v); entry.ccNumber = (int)v;
} else if (key == "targetType") {
reader.read(&targetTypeStr);
} else if (key == "targetId") {
reader.read(&entry.targetId);
} else if (key == "minValue") {
double v; reader.read(&v); entry.minValue = (float)v;
} else if (key == "maxValue") {
double v; reader.read(&v); entry.maxValue = (float)v;
} else {
reader.skip_property();
}
// Consume comma separator
if (reader.peek() == ',') {
reader.consume(',');
}
}
reader.consume('}'); // end object
if (!targetTypeStr.empty()) {
entry.targetType = MidiMappingEntry::targetTypeFromString(targetTypeStr);
}
entries.push_back(entry);
// Consume comma separator between array elements
if (reader.peek() == ',') {
reader.consume(',');
}
}
reader.consume(']'); // end array
setMappings(entries);
}
std::string MidiMapper::defaultConfigPath()
{
// Store alongside other pipedal config
const char* home = std::getenv("HOME");
if (home) {
return std::string(home) + "/.config/pipedal/midi_map.json";
}
return "/etc/pipedal/config/midi_map.json";
}
void MidiMapper::loadFromFile()
{
std::string path = defaultConfigPath();
std::ifstream file(path);
if (!file.is_open()) return;
std::stringstream ss;
ss << file.rdbuf();
std::string content = ss.str();
if (!content.empty()) {
setMappingsFromJson(content);
}
}
void MidiMapper::saveToFile() const
{
std::string path = defaultConfigPath();
// Ensure directory exists
std::filesystem::path dir = std::filesystem::path(path).parent_path();
std::error_code ec;
std::filesystem::create_directories(dir, ec);
std::string json = getMappingsJson();
std::ofstream file(path);
if (file.is_open()) {
file << json;
}
}
// ─── Learn mode ─────────────────────────────────────────────────────────────
void MidiMapper::setLearnMode(bool enabled)
{
{
std::lock_guard<std::mutex> lock(learnMutex_);
learnMode_ = enabled;
if (!enabled) {
// Clear last learned on exit
lastLearnedMidiChannel_ = -1;
lastLearnedCcNumber_ = -1;
}
}
}
void MidiMapper::setPendingLearnTarget(MidiTargetType type, int64_t id)
{
std::lock_guard<std::mutex> lock(learnMutex_);
pendingTargetType_ = type;
pendingTargetId_ = id;
}
bool MidiMapper::getLastLearnedEvent(int& outMidiChannel, int& outCcNumber) const
{
std::lock_guard<std::mutex> lock(learnMutex_);
if (lastLearnedMidiChannel_ < 0 || lastLearnedCcNumber_ < 0) return false;
outMidiChannel = lastLearnedMidiChannel_;
outCcNumber = lastLearnedCcNumber_;
return true;
}
void MidiMapper::clearLastLearnedEvent()
{
std::lock_guard<std::mutex> lock(learnMutex_);
lastLearnedMidiChannel_ = -1;
lastLearnedCcNumber_ = -1;
}
bool MidiMapper::commitLearnMapping()
{
std::lock_guard<std::mutex> lock(learnMutex_);
if (lastLearnedMidiChannel_ < 0 || lastLearnedCcNumber_ < 0) {
return false; // No CC event captured yet
}
MidiMappingEntry entry;
entry.midiChannel = lastLearnedMidiChannel_;
entry.ccNumber = lastLearnedCcNumber_;
entry.targetType = pendingTargetType_;
entry.targetId = pendingTargetId_;
// Set sensible defaults based on target type
switch (entry.targetType) {
case MidiTargetType::ChannelVolume:
case MidiTargetType::BusVolume:
case MidiTargetType::MasterVolume:
entry.minValue = -96.0f; // -inf dB
entry.maxValue = 12.0f; // +12 dB max
break;
case MidiTargetType::ChannelPan:
entry.minValue = -1.0f; // full left
entry.maxValue = 1.0f; // full right
break;
case MidiTargetType::ChannelMute:
case MidiTargetType::ChannelSolo:
case MidiTargetType::BusMute:
case MidiTargetType::MasterMute:
entry.minValue = 0.0f; // off
entry.maxValue = 1.0f; // on (threshold 0.5)
break;
}
// Reset learned event so we don't recomit the same one
lastLearnedMidiChannel_ = -1;
lastLearnedCcNumber_ = -1;
// Add to mapping table
{
std::lock_guard<std::mutex> lockMap(mappingsMutex_);
mappings_.push_back(entry);
}
saveToFile();
return true;
}
bool MidiMapper::getPendingLearnTarget(MidiTargetType& outType, int64_t& outId) const
{
std::lock_guard<std::mutex> lock(learnMutex_);
outType = pendingTargetType_;
outId = pendingTargetId_;
return true;
}