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
|
||||||
@@ -25,6 +25,10 @@ export interface ChannelStripProps {
|
|||||||
onPanChange: (channelIndex: number, pan: number) => void;
|
onPanChange: (channelIndex: number, pan: number) => void;
|
||||||
onMuteToggle: (channelIndex: number, mute: boolean) => void;
|
onMuteToggle: (channelIndex: number, mute: boolean) => void;
|
||||||
onSoloToggle: (channelIndex: number, solo: boolean) => void;
|
onSoloToggle: (channelIndex: number, solo: boolean) => void;
|
||||||
|
/** Called when learn mode is active and the user interacts with a control. */
|
||||||
|
onLearnTarget?: (targetType: string, targetId: number) => void;
|
||||||
|
/** True when MIDI learn mode is enabled on the backend. */
|
||||||
|
learnModeActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Styled components ──────────────────────────────────────────────
|
// ── Styled components ──────────────────────────────────────────────
|
||||||
@@ -101,6 +105,8 @@ const ChannelStrip: React.FC<ChannelStripProps> = ({
|
|||||||
onPanChange,
|
onPanChange,
|
||||||
onMuteToggle,
|
onMuteToggle,
|
||||||
onSoloToggle,
|
onSoloToggle,
|
||||||
|
onLearnTarget,
|
||||||
|
learnModeActive,
|
||||||
}) => {
|
}) => {
|
||||||
const volPct = dbToPercent(channel.volume);
|
const volPct = dbToPercent(channel.volume);
|
||||||
|
|
||||||
@@ -108,24 +114,36 @@ const ChannelStrip: React.FC<ChannelStripProps> = ({
|
|||||||
(_: Event, value: number | number[]) => {
|
(_: Event, value: number | number[]) => {
|
||||||
const db = percentToDb(value as number);
|
const db = percentToDb(value as number);
|
||||||
onVolumeChange(channel.channelIndex, db);
|
onVolumeChange(channel.channelIndex, db);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelVolume", channel.channelIndex);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[channel.channelIndex, onVolumeChange]
|
[channel.channelIndex, onVolumeChange, learnModeActive, onLearnTarget]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePan = useCallback(
|
const handlePan = useCallback(
|
||||||
(_: Event, value: number | number[]) => {
|
(_: Event, value: number | number[]) => {
|
||||||
onPanChange(channel.channelIndex, value as number);
|
onPanChange(channel.channelIndex, value as number);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelPan", channel.channelIndex);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[channel.channelIndex, onPanChange]
|
[channel.channelIndex, onPanChange, learnModeActive, onLearnTarget]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMute = useCallback(() => {
|
const handleMute = useCallback(() => {
|
||||||
onMuteToggle(channel.channelIndex, !channel.mute);
|
onMuteToggle(channel.channelIndex, !channel.mute);
|
||||||
}, [channel.channelIndex, channel.mute, onMuteToggle]);
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelMute", channel.channelIndex);
|
||||||
|
}
|
||||||
|
}, [channel.channelIndex, channel.mute, onMuteToggle, learnModeActive, onLearnTarget]);
|
||||||
|
|
||||||
const handleSolo = useCallback(() => {
|
const handleSolo = useCallback(() => {
|
||||||
onSoloToggle(channel.channelIndex, !channel.solo);
|
onSoloToggle(channel.channelIndex, !channel.solo);
|
||||||
}, [channel.channelIndex, channel.solo, onSoloToggle]);
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelSolo", channel.channelIndex);
|
||||||
|
}
|
||||||
|
}, [channel.channelIndex, channel.solo, onSoloToggle, learnModeActive, onLearnTarget]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StripRoot>
|
<StripRoot>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface MasterBusProps {
|
|||||||
bus: MixerBusState;
|
bus: MixerBusState;
|
||||||
onVolumeChange: (busId: number, volumeDb: number) => void;
|
onVolumeChange: (busId: number, volumeDb: number) => void;
|
||||||
onMuteToggle: (busId: number, mute: boolean) => void;
|
onMuteToggle: (busId: number, mute: boolean) => void;
|
||||||
|
/** Called when learn mode is active and the user interacts with a control. */
|
||||||
|
onLearnTarget?: (targetType: string, targetId: number) => void;
|
||||||
|
/** True when MIDI learn mode is enabled on the backend. */
|
||||||
|
learnModeActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Styled ─────────────────────────────────────────────────────────
|
// ── Styled ─────────────────────────────────────────────────────────
|
||||||
@@ -80,6 +84,8 @@ const MasterBus: React.FC<MasterBusProps> = ({
|
|||||||
bus,
|
bus,
|
||||||
onVolumeChange,
|
onVolumeChange,
|
||||||
onMuteToggle,
|
onMuteToggle,
|
||||||
|
onLearnTarget,
|
||||||
|
learnModeActive,
|
||||||
}) => {
|
}) => {
|
||||||
const volPct = dbToPercent(bus.volume);
|
const volPct = dbToPercent(bus.volume);
|
||||||
|
|
||||||
@@ -87,13 +93,21 @@ const MasterBus: React.FC<MasterBusProps> = ({
|
|||||||
(_: Event, value: number | number[]) => {
|
(_: Event, value: number | number[]) => {
|
||||||
const db = ((value as number) / 100) * 72 - 60;
|
const db = ((value as number) / 100) * 72 - 60;
|
||||||
onVolumeChange(bus.id, db);
|
onVolumeChange(bus.id, db);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
const targetType = bus.type === "Master" ? "masterVolume" : "busVolume";
|
||||||
|
onLearnTarget(targetType, bus.id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[bus.id, onVolumeChange]
|
[bus.id, bus.type, onVolumeChange, learnModeActive, onLearnTarget]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMute = useCallback(() => {
|
const handleMute = useCallback(() => {
|
||||||
onMuteToggle(bus.id, !bus.mute);
|
onMuteToggle(bus.id, !bus.mute);
|
||||||
}, [bus.id, bus.mute, onMuteToggle]);
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
const targetType = bus.type === "Master" ? "masterMute" : "busMute";
|
||||||
|
onLearnTarget(targetType, bus.id);
|
||||||
|
}
|
||||||
|
}, [bus.id, bus.mute, bus.type, onMuteToggle, learnModeActive, onLearnTarget]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BusRoot>
|
<BusRoot>
|
||||||
|
|||||||
@@ -0,0 +1,713 @@
|
|||||||
|
// MidiMappingPanel — MIDI control surface mapping configuration
|
||||||
|
//
|
||||||
|
// Manages MIDI learn mode, displays current CC→mixer parameter mappings,
|
||||||
|
// and allows manual mapping creation/deletion.
|
||||||
|
// Communicates with the pipedald backend via MIDI WebSocket commands
|
||||||
|
// (mixerGetMidiMappings, mixerSetMidiLearnMode, mixerCommitMidiLearn, etc.)
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Select from "@mui/material/Select";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import FormControl from "@mui/material/FormControl";
|
||||||
|
import InputLabel from "@mui/material/InputLabel";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import Switch from "@mui/material/Switch";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Delete from "@mui/icons-material/Delete";
|
||||||
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import MusicNote from "@mui/icons-material/MusicNote";
|
||||||
|
import Devices from "@mui/icons-material/Devices";
|
||||||
|
import type { MixerWsHandle } from "./useMixerWS";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MidiMappingEntry {
|
||||||
|
midiChannel: number;
|
||||||
|
ccNumber: number;
|
||||||
|
targetType: string;
|
||||||
|
targetId: number;
|
||||||
|
minValue: number;
|
||||||
|
maxValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MidiMappingPanelProps {
|
||||||
|
ws: MixerWsHandle;
|
||||||
|
/** Fired when learn mode state changes (so parent can prop drill). */
|
||||||
|
onLearnModeChange?: (enabled: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Target type labels ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TARGET_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: "channelVolume", label: "Ch Volume" },
|
||||||
|
{ value: "channelPan", label: "Ch Pan" },
|
||||||
|
{ value: "channelMute", label: "Ch Mute" },
|
||||||
|
{ value: "channelSolo", label: "Ch Solo" },
|
||||||
|
{ value: "busVolume", label: "Bus Volume" },
|
||||||
|
{ value: "busMute", label: "Bus Mute" },
|
||||||
|
{ value: "masterVolume", label: "Master Volume" },
|
||||||
|
{ value: "masterMute", label: "Master Mute" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function targetTypeLabel(type: string): string {
|
||||||
|
return TARGET_TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Learn feedback poll interval ───────────────────────────────────
|
||||||
|
|
||||||
|
const LEARN_POLL_MS = 400;
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function MidiMappingPanel({
|
||||||
|
ws,
|
||||||
|
onLearnModeChange,
|
||||||
|
disabled,
|
||||||
|
}: MidiMappingPanelProps) {
|
||||||
|
// ── Dialog open state ───────────────────────────────────────────
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// ── Mapping list state ──────────────────────────────────────────
|
||||||
|
const [mappings, setMappings] = useState<MidiMappingEntry[]>([]);
|
||||||
|
const [mappingError, setMappingError] = useState<string | null>(null);
|
||||||
|
const [loadingMappings, setLoadingMappings] = useState(false);
|
||||||
|
|
||||||
|
// ── Learn mode state ────────────────────────────────────────────
|
||||||
|
const [learnEnabled, setLearnEnabled] = useState(false);
|
||||||
|
const [learnPending, setLearnPending] = useState(false);
|
||||||
|
const [capturedChannel, setCapturedChannel] = useState<number | null>(null);
|
||||||
|
const [capturedCc, setCapturedCc] = useState<number | null>(null);
|
||||||
|
const learnPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// ── Manual add state ────────────────────────────────────────────
|
||||||
|
const [manualOpen, setManualOpen] = useState(false);
|
||||||
|
const [manualTargetType, setManualTargetType] = useState("channelVolume");
|
||||||
|
const [manualTargetId, setManualTargetId] = useState(0);
|
||||||
|
const [manualCcNumber, setManualCcNumber] = useState(7);
|
||||||
|
const [manualMidiChannel, setManualMidiChannel] = useState(-1);
|
||||||
|
const [manualMinValue, setManualMinValue] = useState(-96);
|
||||||
|
const [manualMaxValue, setManualMaxValue] = useState(12);
|
||||||
|
|
||||||
|
// ── Fetch mappings ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchMappings = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
setLoadingMappings(true);
|
||||||
|
try {
|
||||||
|
const raw = (await ws.send("mixerGetMidiMappings")) as string;
|
||||||
|
const parsed = JSON.parse(raw) as MidiMappingEntry[];
|
||||||
|
setMappings(Array.isArray(parsed) ? parsed : []);
|
||||||
|
setMappingError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoadingMappings(false);
|
||||||
|
}
|
||||||
|
}, [ws]);
|
||||||
|
|
||||||
|
// ── Learn mode ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toggleLearn = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
const newState = !learnEnabled;
|
||||||
|
try {
|
||||||
|
await ws.send("mixerSetMidiLearnMode", { enabled: newState });
|
||||||
|
setLearnEnabled(newState);
|
||||||
|
// Reset captured event when toggling
|
||||||
|
if (!newState) {
|
||||||
|
setCapturedChannel(null);
|
||||||
|
setCapturedCc(null);
|
||||||
|
setLearnPending(false);
|
||||||
|
} else {
|
||||||
|
setLearnPending(true);
|
||||||
|
}
|
||||||
|
onLearnModeChange?.(newState);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
}
|
||||||
|
}, [ws, learnEnabled, onLearnModeChange]);
|
||||||
|
|
||||||
|
// ── Poll for captured CC event during learn mode ────────────────
|
||||||
|
|
||||||
|
const pollLearnedEvent = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected" || !learnEnabled) return;
|
||||||
|
try {
|
||||||
|
const raw = (await ws.send("mixerGetLastLearnedMidiEvent")) as string;
|
||||||
|
// Response format: {"hasEvent":true,"midiChannel":0,"ccNumber":7} or similar
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
hasEvent: boolean;
|
||||||
|
midiChannel?: number;
|
||||||
|
ccNumber?: number;
|
||||||
|
};
|
||||||
|
if (parsed.hasEvent && parsed.midiChannel !== undefined && parsed.ccNumber !== undefined) {
|
||||||
|
setCapturedChannel(parsed.midiChannel);
|
||||||
|
setCapturedCc(parsed.ccNumber);
|
||||||
|
setLearnPending(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Poll errors are expected during reconnect — ignore silently
|
||||||
|
}
|
||||||
|
}, [ws, learnEnabled]);
|
||||||
|
|
||||||
|
// Start/stop learn poll interval
|
||||||
|
useEffect(() => {
|
||||||
|
if (learnEnabled) {
|
||||||
|
learnPollRef.current = setInterval(pollLearnedEvent, LEARN_POLL_MS);
|
||||||
|
} else {
|
||||||
|
if (learnPollRef.current !== null) {
|
||||||
|
clearInterval(learnPollRef.current);
|
||||||
|
learnPollRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (learnPollRef.current !== null) {
|
||||||
|
clearInterval(learnPollRef.current);
|
||||||
|
learnPollRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [learnEnabled, pollLearnedEvent]);
|
||||||
|
|
||||||
|
// ── Commit learn mapping ────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleCommit = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
try {
|
||||||
|
const success = (await ws.send("mixerCommitMidiLearn")) as boolean;
|
||||||
|
if (success) {
|
||||||
|
setCapturedChannel(null);
|
||||||
|
setCapturedCc(null);
|
||||||
|
setLearnPending(true);
|
||||||
|
await fetchMappings();
|
||||||
|
} else {
|
||||||
|
setMappingError("Failed to commit mapping — no CC event captured");
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
}
|
||||||
|
}, [ws, fetchMappings]);
|
||||||
|
|
||||||
|
// ── Delete mapping ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (ch: number, cc: number) => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
try {
|
||||||
|
await ws.send("mixerRemoveMidiMapping", { midiChannel: ch, ccNumber: cc });
|
||||||
|
await fetchMappings();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ws, fetchMappings],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Clear all mappings ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleClearAll = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
try {
|
||||||
|
await ws.send("mixerClearMidiMappings");
|
||||||
|
await fetchMappings();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
}
|
||||||
|
}, [ws, fetchMappings]);
|
||||||
|
|
||||||
|
// ── Manual add mapping ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleManualAdd = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
try {
|
||||||
|
await ws.send("mixerAddMidiMapping", {
|
||||||
|
midiChannel: manualMidiChannel,
|
||||||
|
ccNumber: manualCcNumber,
|
||||||
|
targetType: manualTargetType,
|
||||||
|
targetId: manualTargetId,
|
||||||
|
minValue: manualMinValue,
|
||||||
|
maxValue: manualMaxValue,
|
||||||
|
});
|
||||||
|
setManualOpen(false);
|
||||||
|
// Reset defaults
|
||||||
|
setManualTargetType("channelVolume");
|
||||||
|
setManualTargetId(0);
|
||||||
|
setManualCcNumber(7);
|
||||||
|
setManualMidiChannel(-1);
|
||||||
|
setManualMinValue(-96);
|
||||||
|
setManualMaxValue(12);
|
||||||
|
await fetchMappings();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
}
|
||||||
|
}, [ws, manualTargetType, manualTargetId, manualCcNumber, manualMidiChannel, manualMinValue, manualMaxValue, fetchMappings]);
|
||||||
|
|
||||||
|
// ── Fetch mappings when dialog opens ────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && ws.status === "connected") {
|
||||||
|
fetchMappings();
|
||||||
|
}
|
||||||
|
}, [open, ws.status, fetchMappings]);
|
||||||
|
|
||||||
|
// ── Reset learn state on disconnect ─────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ws.status !== "connected") {
|
||||||
|
setLearnEnabled(false);
|
||||||
|
setCapturedChannel(null);
|
||||||
|
setCapturedCc(null);
|
||||||
|
setLearnPending(false);
|
||||||
|
}
|
||||||
|
}, [ws.status]);
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const canLearn = ws.status === "connected" && !disabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── MIDI button in toolbar ── */}
|
||||||
|
<Tooltip title="MIDI control surface mapping">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={ws.status !== "connected" || disabled}
|
||||||
|
sx={{
|
||||||
|
p: 0.5,
|
||||||
|
color: learnEnabled ? "#f44336" : undefined,
|
||||||
|
animation: learnEnabled ? "pulse 1.2s ease-in-out infinite" : undefined,
|
||||||
|
"@keyframes pulse": {
|
||||||
|
"0%, 100%": { opacity: 1 },
|
||||||
|
"50%": { opacity: 0.4 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Devices sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* ── Main dialog ── */}
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{ sx: { maxHeight: "80vh" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Devices sx={{ fontSize: 20 }} />
|
||||||
|
<span>MIDI Control Surface Mapping</span>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
{/* ── Learn mode section ── */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: learnEnabled ? "rgba(244,67,54,0.08)" : "rgba(255,255,255,0.03)",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: learnEnabled ? "rgba(244,67,54,0.3)" : "rgba(255,255,255,0.08)",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 1 }}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<MusicNote sx={{ fontSize: 18, color: learnEnabled ? "#f44336" : undefined }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
MIDI Learn
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={learnEnabled}
|
||||||
|
onChange={toggleLearn}
|
||||||
|
disabled={!canLearn}
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 500 }}>
|
||||||
|
{learnEnabled ? "ON" : "OFF"}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
labelPlacement="start"
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Learn workflow status */}
|
||||||
|
{learnEnabled && (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
|
{/* Step 1: touch a control */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: "#f44336",
|
||||||
|
display: "inline-block",
|
||||||
|
animation: "pulse 1.2s ease-in-out infinite",
|
||||||
|
"@keyframes pulse": {
|
||||||
|
"0%, 100%": { opacity: 1 },
|
||||||
|
"50%": { opacity: 0.3 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
1. Touch a control in the mixer (fader, mute, solo)
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Step 2: move hardware fader */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
opacity: capturedCc !== null ? 0.4 : 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: capturedCc !== null ? "#4caf50" : "#f44336",
|
||||||
|
display: "inline-block",
|
||||||
|
animation: capturedCc !== null ? "none" : "pulse 1.2s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
2. Move a hardware fader/knob
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Step 3: commit */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
opacity: capturedCc !== null ? 1 : 0.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: capturedCc !== null ? "#f44336" : "#555",
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
3. Click "Commit" to save the mapping
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Captured event display */}
|
||||||
|
{capturedCc !== null && capturedChannel !== null && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: "rgba(76,175,80,0.12)",
|
||||||
|
border: "1px solid rgba(76,175,80,0.3)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: "#81c784" }}>
|
||||||
|
Captured: CC#{capturedCc} from Channel {capturedChannel + 1}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={handleCommit}
|
||||||
|
sx={{ minWidth: 80, fontSize: 11 }}
|
||||||
|
>
|
||||||
|
Commit
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capturedCc === null && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontStyle: "italic", opacity: 0.5, mt: 0.5 }}
|
||||||
|
>
|
||||||
|
Waiting for MIDI CC event...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Error banner ── */}
|
||||||
|
{mappingError && (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
sx={{ py: 0, px: 1, fontSize: 11 }}
|
||||||
|
onClose={() => setMappingError(null)}
|
||||||
|
>
|
||||||
|
{mappingError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Manual add mapping ── */}
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Add sx={{ fontSize: 14 }} />}
|
||||||
|
onClick={() => setManualOpen(true)}
|
||||||
|
disabled={!canLearn}
|
||||||
|
sx={{ fontSize: 11, textTransform: "none" }}
|
||||||
|
>
|
||||||
|
Add Mapping Manually
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Current mappings list ── */}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 0.5 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Current Mappings ({mappings.length})
|
||||||
|
</Typography>
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={!canLearn}
|
||||||
|
sx={{ fontSize: 10, textTransform: "none", minWidth: 0, p: 0.5 }}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loadingMappings && (
|
||||||
|
<Typography variant="caption" sx={{ opacity: 0.5 }}>
|
||||||
|
Loading...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loadingMappings && mappings.length === 0 && (
|
||||||
|
<Typography variant="caption" sx={{ opacity: 0.4, fontStyle: "italic" }}>
|
||||||
|
No MIDI mappings configured. Use MIDI Learn or add mappings manually.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5, mt: 0.5 }}>
|
||||||
|
{mappings.map((entry, idx) => (
|
||||||
|
<Box
|
||||||
|
key={`${entry.midiChannel}-${entry.ccNumber}-${idx}`}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 1,
|
||||||
|
p: 0.75,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: "rgba(255,255,255,0.03)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
"&:hover": { bgcolor: "rgba(255,255,255,0.06)" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
|
||||||
|
{/* CC badge */}
|
||||||
|
<Chip
|
||||||
|
label={`CC#${entry.ccNumber}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: 10, height: 20 }}
|
||||||
|
/>
|
||||||
|
{/* MIDI channel badge */}
|
||||||
|
<Chip
|
||||||
|
label={entry.midiChannel < 0 ? "Omni" : `Ch ${entry.midiChannel + 1}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: 10, height: 20 }}
|
||||||
|
/>
|
||||||
|
{/* Arrow */}
|
||||||
|
<Typography variant="caption" sx={{ opacity: 0.4 }}>→</Typography>
|
||||||
|
{/* Target */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{targetTypeLabel(entry.targetType)} #{entry.targetId}
|
||||||
|
</Typography>
|
||||||
|
{/* Range info */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ opacity: 0.4, fontSize: 10, display: { xs: "none", sm: "inline" } }}
|
||||||
|
>
|
||||||
|
({entry.minValue} to {entry.maxValue})
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleDelete(entry.midiChannel, entry.ccNumber)}
|
||||||
|
disabled={!canLearn}
|
||||||
|
sx={{ p: 0.3 }}
|
||||||
|
>
|
||||||
|
<Delete sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Manual add dialog ── */}
|
||||||
|
<Dialog
|
||||||
|
open={manualOpen}
|
||||||
|
onClose={() => setManualOpen(false)}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Add MIDI Mapping</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, mt: 0.5 }}>
|
||||||
|
{/* Target type */}
|
||||||
|
<FormControl size="small" fullWidth>
|
||||||
|
<InputLabel>Target Parameter</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={manualTargetType}
|
||||||
|
label="Target Parameter"
|
||||||
|
onChange={(e) => setManualTargetType(e.target.value)}
|
||||||
|
>
|
||||||
|
{TARGET_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Target ID */}
|
||||||
|
<TextField
|
||||||
|
label="Target ID (channel index or bus ID)"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={manualTargetId}
|
||||||
|
onChange={(e) => setManualTargetId(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* MIDI Channel */}
|
||||||
|
<TextField
|
||||||
|
label="MIDI Channel (-1 = Omni)"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={manualMidiChannel}
|
||||||
|
onChange={(e) => setManualMidiChannel(parseInt(e.target.value) || -1)}
|
||||||
|
inputProps={{ min: -1, max: 15 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* CC Number */}
|
||||||
|
<TextField
|
||||||
|
label="CC Number (0-127)"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={manualCcNumber}
|
||||||
|
onChange={(e) => setManualCcNumber(Math.min(127, Math.max(0, parseInt(e.target.value) || 0)))}
|
||||||
|
inputProps={{ min: 0, max: 127 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Min/Max range */}
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Min Value"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={manualMinValue}
|
||||||
|
onChange={(e) => setManualMinValue(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Max Value"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={manualMaxValue}
|
||||||
|
onChange={(e) => setManualMaxValue(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setManualOpen(false)} color="secondary">Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleManualAdd}
|
||||||
|
variant="contained"
|
||||||
|
disabled={ws.status !== "connected" || disabled}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,13 +11,16 @@ import Typography from "@mui/material/Typography";
|
|||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import ArrowBack from "@mui/icons-material/ArrowBack";
|
import ArrowBack from "@mui/icons-material/ArrowBack";
|
||||||
import Refresh from "@mui/icons-material/Refresh";
|
import Refresh from "@mui/icons-material/Refresh";
|
||||||
|
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
|
||||||
import Alert from "@mui/material/Alert";
|
import Alert from "@mui/material/Alert";
|
||||||
import { styled } from "@mui/material/styles";
|
import { styled } from "@mui/material/styles";
|
||||||
|
|
||||||
import { useMixerWS, type MixerState, type MixerChannelState, type MixerBusState } from "./useMixerWS";
|
import { useMixerWS, type MixerState, type MixerChannelState, type MixerBusState, type MixerOutputRouteState } from "./useMixerWS";
|
||||||
import ChannelStrip from "./ChannelStrip";
|
import ChannelStrip from "./ChannelStrip";
|
||||||
import MasterBus from "./MasterBus";
|
import MasterBus from "./MasterBus";
|
||||||
import MixerScenePanel from "./MixerScenePanel";
|
import MixerScenePanel from "./MixerScenePanel";
|
||||||
|
import OutputRoutingPanel from "./OutputRoutingPanel";
|
||||||
|
import MidiMappingPanel from "./MidiMappingPanel";
|
||||||
|
|
||||||
// ── Props ──────────────────────────────────────────────────────────
|
// ── Props ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -100,6 +103,22 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
|||||||
const ws = useMixerWS();
|
const ws = useMixerWS();
|
||||||
const { state, fetchError, fetchState } = useMixerState(ws);
|
const { state, fetchError, fetchState } = useMixerState(ws);
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [showOutputRouting, setShowOutputRouting] = useState(false);
|
||||||
|
const [learnModeActive, setLearnModeActive] = useState(false);
|
||||||
|
|
||||||
|
// MIDI learn target: when learn mode is active and a control is touched,
|
||||||
|
// set the pending target on the backend
|
||||||
|
const handleLearnTarget = useCallback(
|
||||||
|
(targetType: string, targetId: number) => {
|
||||||
|
if (learnModeActive) {
|
||||||
|
ws.send("mixerSetMidiLearnTarget", { targetType, targetId }).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ws, learnModeActive],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Command helpers ────────────────────────────────────────────
|
// ── Command helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
const handleChannelVolume = useCallback(
|
const handleChannelVolume = useCallback(
|
||||||
@@ -178,6 +197,19 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
|||||||
>
|
>
|
||||||
<Refresh />
|
<Refresh />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowOutputRouting(true)}
|
||||||
|
disabled={ws.status !== "connected"}
|
||||||
|
title="Configure output routing"
|
||||||
|
>
|
||||||
|
<SettingsEthernetIcon />
|
||||||
|
</IconButton>
|
||||||
|
<MidiMappingPanel
|
||||||
|
ws={ws}
|
||||||
|
disabled={ws.status !== "connected"}
|
||||||
|
onLearnModeChange={setLearnModeActive}
|
||||||
|
/>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -240,6 +272,8 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
|||||||
onPanChange={handleChannelPan}
|
onPanChange={handleChannelPan}
|
||||||
onMuteToggle={handleChannelMute}
|
onMuteToggle={handleChannelMute}
|
||||||
onSoloToggle={handleChannelSolo}
|
onSoloToggle={handleChannelSolo}
|
||||||
|
onLearnTarget={handleLearnTarget}
|
||||||
|
learnModeActive={learnModeActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -253,6 +287,8 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
|||||||
bus={bus}
|
bus={bus}
|
||||||
onVolumeChange={handleBusVolume}
|
onVolumeChange={handleBusVolume}
|
||||||
onMuteToggle={handleBusMute}
|
onMuteToggle={handleBusMute}
|
||||||
|
onLearnTarget={handleLearnTarget}
|
||||||
|
learnModeActive={learnModeActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -265,6 +301,8 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
|||||||
bus={masterBus}
|
bus={masterBus}
|
||||||
onVolumeChange={handleBusVolume}
|
onVolumeChange={handleBusVolume}
|
||||||
onMuteToggle={handleBusMute}
|
onMuteToggle={handleBusMute}
|
||||||
|
onLearnTarget={handleLearnTarget}
|
||||||
|
learnModeActive={learnModeActive}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -278,6 +316,18 @@ const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
|||||||
</StripRow>
|
</StripRow>
|
||||||
)}
|
)}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
|
|
||||||
|
{/* Output routing panel overlay */}
|
||||||
|
{showOutputRouting && (
|
||||||
|
<OutputRoutingPanel
|
||||||
|
ws={ws}
|
||||||
|
buses={state?.buses ?? []}
|
||||||
|
routes={state?.outputRoutes ?? []}
|
||||||
|
physicalOutputCount={32}
|
||||||
|
onRoutesChanged={fetchState}
|
||||||
|
onClose={() => setShowOutputRouting(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user