diff --git a/src/MidiLearnMode.cpp b/src/MidiLearnMode.cpp new file mode 100644 index 0000000..cd45599 --- /dev/null +++ b/src/MidiLearnMode.cpp @@ -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 lock(mutex_); + enabled_ = enabled; + if (!enabled) { + capturedMidiChannel_ = -1; + capturedCcNumber_ = -1; + } +} + +void MidiLearnMode::setPendingTarget(MidiTargetType type, int64_t id) +{ + std::lock_guard lock(mutex_); + pendingTargetType_ = type; + pendingTargetId_ = id; +} + +bool MidiLearnMode::getPendingTarget(MidiTargetType& outType, int64_t& outId) const +{ + std::lock_guard lock(mutex_); + outType = pendingTargetType_; + outId = pendingTargetId_; + return true; +} + +void MidiLearnMode::captureEvent(int midiChannel, int ccNumber) +{ + std::lock_guard lock(mutex_); + if (!enabled_) return; + capturedMidiChannel_ = midiChannel; + capturedCcNumber_ = ccNumber; +} + +bool MidiLearnMode::hasCapturedEvent() const +{ + std::lock_guard lock(mutex_); + return capturedMidiChannel_ >= 0 && capturedCcNumber_ >= 0; +} + +bool MidiLearnMode::getCapturedEvent(int& outMidiChannel, int& outCcNumber) const +{ + std::lock_guard lock(mutex_); + if (capturedMidiChannel_ < 0 || capturedCcNumber_ < 0) return false; + outMidiChannel = capturedMidiChannel_; + outCcNumber = capturedCcNumber_; + return true; +} + +MidiMappingEntry MidiLearnMode::buildMapping() const +{ + MidiMappingEntry entry; + + std::lock_guard 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 lock(mutex_); + capturedMidiChannel_ = -1; + capturedCcNumber_ = -1; +} + +void MidiLearnMode::reset() +{ + std::lock_guard lock(mutex_); + enabled_ = false; + pendingTargetType_ = MidiTargetType::ChannelVolume; + pendingTargetId_ = 0; + capturedMidiChannel_ = -1; + capturedCcNumber_ = -1; +} diff --git a/src/MidiLearnMode.hpp b/src/MidiLearnMode.hpp new file mode 100644 index 0000000..628baf9 --- /dev/null +++ b/src/MidiLearnMode.hpp @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Ourpad Network +// See LICENSE file in the project root for full license text. + +#pragma once + +#include +#include +#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 diff --git a/src/MidiMapper.cpp b/src/MidiMapper.cpp new file mode 100644 index 0000000..47a4a5b --- /dev/null +++ b/src/MidiMapper.cpp @@ -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 +#include +#include +#include + +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(event.buffer[0] & 0x0F); + int ccNumber = static_cast(event.buffer[1]); + uint8_t ccValue = event.buffer[2]; + + // ── Learn mode: capture the CC event ── + if (learnMode_) { + std::lock_guard lock(learnMutex_); + lastLearnedMidiChannel_ = midiChannel; + lastLearnedCcNumber_ = ccNumber; + } + + // ── Snapshot the current mapping table ── + std::vector mappingsSnapshot; + { + std::lock_guard 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(ccValue) / 127.0f; + float mappedValue = entry.minValue + normalized * range; + + switch (entry.targetType) { + case MidiTargetType::ChannelVolume: { + auto* ch = mixerEngine_->getChannel(static_cast(entry.targetId)); + if (ch) ch->setVolume(mappedValue); + break; + } + case MidiTargetType::ChannelPan: { + auto* ch = mixerEngine_->getChannel(static_cast(entry.targetId)); + if (ch) ch->setPan(mappedValue); + break; + } + case MidiTargetType::ChannelMute: { + auto* ch = mixerEngine_->getChannel(static_cast(entry.targetId)); + if (ch) ch->setMute(mappedValue >= 0.5f); + break; + } + case MidiTargetType::ChannelSolo: { + auto* ch = mixerEngine_->getChannel(static_cast(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& mappings) +{ + std::lock_guard lock(mappingsMutex_); + mappings_ = mappings; +} + +void MidiMapper::addMapping(const MidiMappingEntry& entry) +{ + std::lock_guard lock(mappingsMutex_); + mappings_.push_back(entry); +} + +bool MidiMapper::removeMapping(int midiChannel, int ccNumber) +{ + std::lock_guard 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 lock(mappingsMutex_); + if (index >= mappings_.size()) return false; + mappings_.erase(mappings_.begin() + static_cast(index)); + return true; +} + +void MidiMapper::clearMappings() +{ + std::lock_guard lock(mappingsMutex_); + mappings_.clear(); +} + +std::vector MidiMapper::getMappings() const +{ + std::lock_guard 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 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 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 lock(learnMutex_); + pendingTargetType_ = type; + pendingTargetId_ = id; +} + +bool MidiMapper::getLastLearnedEvent(int& outMidiChannel, int& outCcNumber) const +{ + std::lock_guard lock(learnMutex_); + if (lastLearnedMidiChannel_ < 0 || lastLearnedCcNumber_ < 0) return false; + outMidiChannel = lastLearnedMidiChannel_; + outCcNumber = lastLearnedCcNumber_; + return true; +} + +void MidiMapper::clearLastLearnedEvent() +{ + std::lock_guard lock(learnMutex_); + lastLearnedMidiChannel_ = -1; + lastLearnedCcNumber_ = -1; +} + +bool MidiMapper::commitLearnMapping() +{ + std::lock_guard 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 lockMap(mappingsMutex_); + mappings_.push_back(entry); + } + + saveToFile(); + return true; +} + +bool MidiMapper::getPendingLearnTarget(MidiTargetType& outType, int64_t& outId) const +{ + std::lock_guard lock(learnMutex_); + outType = pendingTargetType_; + outId = pendingTargetId_; + return true; +} diff --git a/src/MidiMapper.hpp b/src/MidiMapper.hpp new file mode 100644 index 0000000..4982320 --- /dev/null +++ b/src/MidiMapper.hpp @@ -0,0 +1,149 @@ +// Copyright (c) 2026 Ourpad Network +// See LICENSE file in the project root for full license text. + +#pragma once + +#include +#include +#include +#include +#include + +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& 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 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 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 diff --git a/vite/src/pipedal/mixer/ChannelStrip.tsx b/vite/src/pipedal/mixer/ChannelStrip.tsx index 55937b0..3a52f1d 100644 --- a/vite/src/pipedal/mixer/ChannelStrip.tsx +++ b/vite/src/pipedal/mixer/ChannelStrip.tsx @@ -25,6 +25,10 @@ export interface ChannelStripProps { onPanChange: (channelIndex: number, pan: number) => void; onMuteToggle: (channelIndex: number, mute: 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 ────────────────────────────────────────────── @@ -101,6 +105,8 @@ const ChannelStrip: React.FC = ({ onPanChange, onMuteToggle, onSoloToggle, + onLearnTarget, + learnModeActive, }) => { const volPct = dbToPercent(channel.volume); @@ -108,24 +114,36 @@ const ChannelStrip: React.FC = ({ (_: Event, value: number | number[]) => { const db = percentToDb(value as number); onVolumeChange(channel.channelIndex, db); + if (learnModeActive && onLearnTarget) { + onLearnTarget("channelVolume", channel.channelIndex); + } }, - [channel.channelIndex, onVolumeChange] + [channel.channelIndex, onVolumeChange, learnModeActive, onLearnTarget] ); const handlePan = useCallback( (_: Event, value: number | 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(() => { 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(() => { 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 ( diff --git a/vite/src/pipedal/mixer/MasterBus.tsx b/vite/src/pipedal/mixer/MasterBus.tsx index 4d6fe0b..eec85d6 100644 --- a/vite/src/pipedal/mixer/MasterBus.tsx +++ b/vite/src/pipedal/mixer/MasterBus.tsx @@ -20,6 +20,10 @@ export interface MasterBusProps { bus: MixerBusState; onVolumeChange: (busId: number, volumeDb: number) => 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 ───────────────────────────────────────────────────────── @@ -80,6 +84,8 @@ const MasterBus: React.FC = ({ bus, onVolumeChange, onMuteToggle, + onLearnTarget, + learnModeActive, }) => { const volPct = dbToPercent(bus.volume); @@ -87,13 +93,21 @@ const MasterBus: React.FC = ({ (_: Event, value: number | number[]) => { const db = ((value as number) / 100) * 72 - 60; 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(() => { 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 ( diff --git a/vite/src/pipedal/mixer/MidiMappingPanel.tsx b/vite/src/pipedal/mixer/MidiMappingPanel.tsx new file mode 100644 index 0000000..a99188e --- /dev/null +++ b/vite/src/pipedal/mixer/MidiMappingPanel.tsx @@ -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([]); + const [mappingError, setMappingError] = useState(null); + const [loadingMappings, setLoadingMappings] = useState(false); + + // ── Learn mode state ──────────────────────────────────────────── + const [learnEnabled, setLearnEnabled] = useState(false); + const [learnPending, setLearnPending] = useState(false); + const [capturedChannel, setCapturedChannel] = useState(null); + const [capturedCc, setCapturedCc] = useState(null); + const learnPollRef = useRef | 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 ── */} + + + 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 }, + }, + }} + > + + + + + + {/* ── Main dialog ── */} + setOpen(false)} + maxWidth="sm" + fullWidth + PaperProps={{ sx: { maxHeight: "80vh" } }} + > + + + MIDI Control Surface Mapping + + + + {/* ── Learn mode section ── */} + + + + + + MIDI Learn + + + + } + label={ + + {learnEnabled ? "ON" : "OFF"} + + } + labelPlacement="start" + sx={{ m: 0 }} + /> + + + {/* Learn workflow status */} + {learnEnabled && ( + + {/* Step 1: touch a control */} + + + 1. Touch a control in the mixer (fader, mute, solo) + + + {/* Step 2: move hardware fader */} + + + 2. Move a hardware fader/knob + + + {/* Step 3: commit */} + + + 3. Click "Commit" to save the mapping + + + {/* Captured event display */} + {capturedCc !== null && capturedChannel !== null && ( + + + + Captured: CC#{capturedCc} from Channel {capturedChannel + 1} + + + + + )} + + {capturedCc === null && ( + + Waiting for MIDI CC event... + + )} + + )} + + + {/* ── Error banner ── */} + {mappingError && ( + setMappingError(null)} + > + {mappingError} + + )} + + {/* ── Manual add mapping ── */} + + + + + {/* ── Current mappings list ── */} + + + + Current Mappings ({mappings.length}) + + {mappings.length > 0 && ( + + )} + + + {loadingMappings && ( + + Loading... + + )} + + {!loadingMappings && mappings.length === 0 && ( + + No MIDI mappings configured. Use MIDI Learn or add mappings manually. + + )} + + {mappings.length > 0 && ( + + {mappings.map((entry, idx) => ( + + + {/* CC badge */} + + {/* MIDI channel badge */} + + {/* Arrow */} + + {/* Target */} + + {targetTypeLabel(entry.targetType)} #{entry.targetId} + + {/* Range info */} + + ({entry.minValue} to {entry.maxValue}) + + + + handleDelete(entry.midiChannel, entry.ccNumber)} + disabled={!canLearn} + sx={{ p: 0.3 }} + > + + + + ))} + + )} + + + + + + + + + {/* ── Manual add dialog ── */} + setManualOpen(false)} + maxWidth="xs" + fullWidth + > + Add MIDI Mapping + + + {/* Target type */} + + Target Parameter + + + + {/* Target ID */} + setManualTargetId(parseInt(e.target.value) || 0)} + /> + + {/* MIDI Channel */} + setManualMidiChannel(parseInt(e.target.value) || -1)} + inputProps={{ min: -1, max: 15 }} + /> + + {/* CC Number */} + setManualCcNumber(Math.min(127, Math.max(0, parseInt(e.target.value) || 0)))} + inputProps={{ min: 0, max: 127 }} + /> + + {/* Min/Max range */} + + setManualMinValue(parseFloat(e.target.value) || 0)} + /> + setManualMaxValue(parseFloat(e.target.value) || 0)} + /> + + + + + + + + + + ); +} diff --git a/vite/src/pipedal/mixer/MixerPage.tsx b/vite/src/pipedal/mixer/MixerPage.tsx index 84a2dc8..f4fe69f 100644 --- a/vite/src/pipedal/mixer/MixerPage.tsx +++ b/vite/src/pipedal/mixer/MixerPage.tsx @@ -11,13 +11,16 @@ import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import ArrowBack from "@mui/icons-material/ArrowBack"; import Refresh from "@mui/icons-material/Refresh"; +import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet"; import Alert from "@mui/material/Alert"; 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 MasterBus from "./MasterBus"; import MixerScenePanel from "./MixerScenePanel"; +import OutputRoutingPanel from "./OutputRoutingPanel"; +import MidiMappingPanel from "./MidiMappingPanel"; // ── Props ────────────────────────────────────────────────────────── @@ -100,6 +103,22 @@ const MixerPage: React.FC = ({ onClose }) => { const ws = useMixerWS(); 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 ──────────────────────────────────────────── const handleChannelVolume = useCallback( @@ -178,6 +197,19 @@ const MixerPage: React.FC = ({ onClose }) => { > + setShowOutputRouting(true)} + disabled={ws.status !== "connected"} + title="Configure output routing" + > + + + = ({ onClose }) => { onPanChange={handleChannelPan} onMuteToggle={handleChannelMute} onSoloToggle={handleChannelSolo} + onLearnTarget={handleLearnTarget} + learnModeActive={learnModeActive} /> ))} @@ -253,6 +287,8 @@ const MixerPage: React.FC = ({ onClose }) => { bus={bus} onVolumeChange={handleBusVolume} onMuteToggle={handleBusMute} + onLearnTarget={handleLearnTarget} + learnModeActive={learnModeActive} /> ))} @@ -265,6 +301,8 @@ const MixerPage: React.FC = ({ onClose }) => { bus={masterBus} onVolumeChange={handleBusVolume} onMuteToggle={handleBusMute} + onLearnTarget={handleLearnTarget} + learnModeActive={learnModeActive} /> )} @@ -278,6 +316,18 @@ const MixerPage: React.FC = ({ onClose }) => { )} + + {/* Output routing panel overlay */} + {showOutputRouting && ( + setShowOutputRouting(false)} + /> + )} ); };