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:
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Ourpad Network
|
||||
// See LICENSE file in the project root for full license text.
|
||||
|
||||
#include "pch.h"
|
||||
#include "MidiLearnMode.hpp"
|
||||
|
||||
using namespace pipedal;
|
||||
|
||||
MidiLearnMode::MidiLearnMode()
|
||||
{
|
||||
}
|
||||
|
||||
MidiLearnMode::~MidiLearnMode()
|
||||
{
|
||||
}
|
||||
|
||||
void MidiLearnMode::setEnabled(bool enabled)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
enabled_ = enabled;
|
||||
if (!enabled) {
|
||||
capturedMidiChannel_ = -1;
|
||||
capturedCcNumber_ = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void MidiLearnMode::setPendingTarget(MidiTargetType type, int64_t id)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
pendingTargetType_ = type;
|
||||
pendingTargetId_ = id;
|
||||
}
|
||||
|
||||
bool MidiLearnMode::getPendingTarget(MidiTargetType& outType, int64_t& outId) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
outType = pendingTargetType_;
|
||||
outId = pendingTargetId_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void MidiLearnMode::captureEvent(int midiChannel, int ccNumber)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!enabled_) return;
|
||||
capturedMidiChannel_ = midiChannel;
|
||||
capturedCcNumber_ = ccNumber;
|
||||
}
|
||||
|
||||
bool MidiLearnMode::hasCapturedEvent() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return capturedMidiChannel_ >= 0 && capturedCcNumber_ >= 0;
|
||||
}
|
||||
|
||||
bool MidiLearnMode::getCapturedEvent(int& outMidiChannel, int& outCcNumber) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (capturedMidiChannel_ < 0 || capturedCcNumber_ < 0) return false;
|
||||
outMidiChannel = capturedMidiChannel_;
|
||||
outCcNumber = capturedCcNumber_;
|
||||
return true;
|
||||
}
|
||||
|
||||
MidiMappingEntry MidiLearnMode::buildMapping() const
|
||||
{
|
||||
MidiMappingEntry entry;
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
entry.midiChannel = capturedMidiChannel_;
|
||||
entry.ccNumber = capturedCcNumber_;
|
||||
entry.targetType = pendingTargetType_;
|
||||
entry.targetId = pendingTargetId_;
|
||||
|
||||
// Sensible default ranges based on target type
|
||||
switch (entry.targetType) {
|
||||
case MidiTargetType::ChannelVolume:
|
||||
case MidiTargetType::BusVolume:
|
||||
case MidiTargetType::MasterVolume:
|
||||
entry.minValue = -96.0f;
|
||||
entry.maxValue = 12.0f;
|
||||
break;
|
||||
case MidiTargetType::ChannelPan:
|
||||
entry.minValue = -1.0f;
|
||||
entry.maxValue = 1.0f;
|
||||
break;
|
||||
case MidiTargetType::ChannelMute:
|
||||
case MidiTargetType::ChannelSolo:
|
||||
case MidiTargetType::BusMute:
|
||||
case MidiTargetType::MasterMute:
|
||||
entry.minValue = 0.0f;
|
||||
entry.maxValue = 1.0f;
|
||||
break;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
void MidiLearnMode::clearCapturedEvent()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
capturedMidiChannel_ = -1;
|
||||
capturedCcNumber_ = -1;
|
||||
}
|
||||
|
||||
void MidiLearnMode::reset()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
enabled_ = false;
|
||||
pendingTargetType_ = MidiTargetType::ChannelVolume;
|
||||
pendingTargetId_ = 0;
|
||||
capturedMidiChannel_ = -1;
|
||||
capturedCcNumber_ = -1;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2026 Ourpad Network
|
||||
// See LICENSE file in the project root for full license text.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include "MidiMapper.hpp"
|
||||
|
||||
namespace pipedal {
|
||||
|
||||
/// MIDI Learn mode state machine.
|
||||
///
|
||||
/// Tracks the three-step learn workflow:
|
||||
/// 1. User enables learn mode and touches a UI control (setPendingTarget)
|
||||
/// 2. User moves a hardware fader — the CC event is captured (captureEvent)
|
||||
/// 3. User confirms — a new MidiMappingEntry is created (commitMapping)
|
||||
///
|
||||
class MidiLearnMode {
|
||||
public:
|
||||
MidiLearnMode();
|
||||
~MidiLearnMode();
|
||||
|
||||
/// Enable or disable learn mode.
|
||||
void setEnabled(bool enabled);
|
||||
bool isEnabled() const { return enabled_; }
|
||||
|
||||
/// Set the mixer parameter that should receive the next learned mapping.
|
||||
/// Call this when the user touches a UI control while in learn mode.
|
||||
void setPendingTarget(MidiTargetType type, int64_t id);
|
||||
|
||||
/// Get the current pending target.
|
||||
bool getPendingTarget(MidiTargetType& outType, int64_t& outId) const;
|
||||
|
||||
/// Capture a MIDI CC event while in learn mode.
|
||||
/// Call this from the RT audio thread when processEvent sees a CC.
|
||||
void captureEvent(int midiChannel, int ccNumber);
|
||||
|
||||
/// Check if a CC event has been captured since learn mode was entered
|
||||
/// or since the last clear().
|
||||
bool hasCapturedEvent() const;
|
||||
|
||||
/// Get the last captured CC event info.
|
||||
/// Returns true if an event was captured.
|
||||
bool getCapturedEvent(int& outMidiChannel, int& outCcNumber) const;
|
||||
|
||||
/// Build a MidiMappingEntry from pending target + captured event.
|
||||
/// Clears the captured event after building (avoids stale recomit).
|
||||
MidiMappingEntry buildMapping() const;
|
||||
|
||||
/// Clear captured event without committing.
|
||||
void clearCapturedEvent();
|
||||
|
||||
/// Reset all learn state.
|
||||
void reset();
|
||||
|
||||
private:
|
||||
bool enabled_ = false;
|
||||
MidiTargetType pendingTargetType_ = MidiTargetType::ChannelVolume;
|
||||
int64_t pendingTargetId_ = 0;
|
||||
int capturedMidiChannel_ = -1;
|
||||
int capturedCcNumber_ = -1;
|
||||
mutable std::mutex mutex_;
|
||||
};
|
||||
|
||||
} // namespace pipedal
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2026 Ourpad Network
|
||||
// See LICENSE file in the project root for full license text.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
#include <memory>
|
||||
|
||||
namespace pipedal {
|
||||
|
||||
class MixerEngine;
|
||||
struct MidiEvent;
|
||||
|
||||
/// Types of mixer parameters that can be mapped from MIDI CC.
|
||||
enum class MidiTargetType {
|
||||
ChannelVolume, ///< Channel fader (-inf .. +12 dB)
|
||||
ChannelPan, ///< Channel pan (-1 .. +1)
|
||||
ChannelMute, ///< Channel mute toggle
|
||||
ChannelSolo, ///< Channel solo toggle
|
||||
BusVolume, ///< Bus fader (-inf .. +12 dB)
|
||||
BusMute, ///< Bus mute toggle
|
||||
MasterVolume, ///< Master bus volume
|
||||
MasterMute, ///< Master bus mute
|
||||
};
|
||||
|
||||
/// A single mapping entry: MIDI CC# + channel → mixer parameter.
|
||||
struct MidiMappingEntry {
|
||||
int midiChannel = -1; ///< MIDI channel (-1 = omni / any)
|
||||
int ccNumber = 0; ///< MIDI CC number (0-127)
|
||||
MidiTargetType targetType = MidiTargetType::ChannelVolume;
|
||||
int64_t targetId = 0; ///< channel index for Channel*, bus ID for Bus*
|
||||
|
||||
/// Output range: CC=0 maps to minValue, CC=127 maps to maxValue.
|
||||
float minValue = 0.0f;
|
||||
float maxValue = 1.0f;
|
||||
|
||||
/// Convert between enum and string (for JSON serialization).
|
||||
static MidiTargetType targetTypeFromString(const std::string& str);
|
||||
static const char* targetTypeToString(MidiTargetType type);
|
||||
};
|
||||
|
||||
/// MIDI CC → mixer parameter mapper.
|
||||
///
|
||||
/// Receives MIDI CC events (0xB0) from the real-time audio thread and applies
|
||||
/// them to the MixerEngine via atomic parameter setters.
|
||||
///
|
||||
/// The mapping table is configured from the non-real-time thread; a mutex
|
||||
/// protects the table while the RT path snapshots the current mapping set.
|
||||
class MidiMapper {
|
||||
public:
|
||||
MidiMapper();
|
||||
~MidiMapper();
|
||||
|
||||
/// Set the mixer engine to control. Must be set before processing events.
|
||||
void setMixerEngine(MixerEngine* engine) { mixerEngine_ = engine; }
|
||||
|
||||
/// Process a MIDI event. Returns true if a mapping consumed the event.
|
||||
/// RT-safe: uses atomic mixer setters directly.
|
||||
bool processEvent(const MidiEvent& event);
|
||||
|
||||
// --- Mapping table management (non-RT thread) ---
|
||||
|
||||
/// Replace the entire mapping table.
|
||||
void setMappings(const std::vector<MidiMappingEntry>& mappings);
|
||||
|
||||
/// Add a single mapping entry.
|
||||
void addMapping(const MidiMappingEntry& entry);
|
||||
|
||||
/// Remove all mappings matching the given MIDI channel and CC number.
|
||||
bool removeMapping(int midiChannel, int ccNumber);
|
||||
|
||||
/// Remove a specific mapping entry by index.
|
||||
bool removeMappingByIndex(size_t index);
|
||||
|
||||
/// Clear all mappings.
|
||||
void clearMappings();
|
||||
|
||||
/// Get a copy of the current mapping table.
|
||||
std::vector<MidiMappingEntry> getMappings() const;
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
/// Serialize mappings to JSON string.
|
||||
std::string getMappingsJson() const;
|
||||
|
||||
/// Deserialize mappings from JSON string.
|
||||
void setMappingsFromJson(const std::string& json);
|
||||
|
||||
/// Default config file path.
|
||||
static std::string defaultConfigPath();
|
||||
|
||||
/// Load mappings from default config file.
|
||||
void loadFromFile();
|
||||
|
||||
/// Save mappings to default config file.
|
||||
void saveToFile() const;
|
||||
|
||||
// --- Learn mode ---
|
||||
|
||||
/// Enable/disable MIDI learn mode.
|
||||
/// When enabled, each incoming CC event is captured and cached
|
||||
/// so the next call to commitLearnMapping() will create a mapping.
|
||||
void setLearnMode(bool enabled);
|
||||
|
||||
/// True if learn mode is active.
|
||||
bool learnMode() const { return learnMode_; }
|
||||
|
||||
/// Set the target parameter for the next learned mapping.
|
||||
/// Call this when the user touches a UI control.
|
||||
void setPendingLearnTarget(MidiTargetType type, int64_t id);
|
||||
|
||||
/// Get the last-learned MIDI event info.
|
||||
/// Returns true if a CC event was captured since learn mode was enabled
|
||||
/// or since the last clearLastLearnedEvent().
|
||||
bool getLastLearnedEvent(int& outMidiChannel, int& outCcNumber) const;
|
||||
|
||||
/// Commit the current pending learn target + last CC into a mapping entry.
|
||||
/// Returns the new entry, or nullopt if no CC was captured.
|
||||
bool commitLearnMapping();
|
||||
|
||||
/// Clear cached last-learned CC event.
|
||||
void clearLastLearnedEvent();
|
||||
|
||||
/// Get pending learn target info.
|
||||
bool getPendingLearnTarget(MidiTargetType& outType, int64_t& outId) const;
|
||||
|
||||
private:
|
||||
MixerEngine* mixerEngine_ = nullptr;
|
||||
|
||||
// Mapping table — mutable for snapshot-copy in RT path
|
||||
std::vector<MidiMappingEntry> mappings_;
|
||||
mutable std::mutex mappingsMutex_;
|
||||
|
||||
// Learn mode state
|
||||
bool learnMode_ = false;
|
||||
MidiTargetType pendingTargetType_ = MidiTargetType::ChannelVolume;
|
||||
int64_t pendingTargetId_ = 0;
|
||||
int lastLearnedMidiChannel_ = -1;
|
||||
int lastLearnedCcNumber_ = -1;
|
||||
mutable std::mutex learnMutex_;
|
||||
|
||||
// Apply a single mapping entry value to the mixer (RT-safe).
|
||||
void applyValue(const MidiMappingEntry& entry, uint8_t ccValue);
|
||||
};
|
||||
|
||||
} // namespace pipedal
|
||||
Reference in New Issue
Block a user