Compare commits
13 Commits
main
...
mixer-engine
| Author | SHA1 | Date | |
|---|---|---|---|
| 120279cbd4 | |||
| 8068f5d168 | |||
| 01584f50da | |||
| 5fd5946ff6 | |||
| 3d00299051 | |||
| 524f02ec9d | |||
| e4e7cd1ca2 | |||
| 0316e4b37f | |||
| 0a76f5734f | |||
| 959da00d7c | |||
| 1854d03c58 | |||
| 0422c91b4e | |||
| df5a317ceb |
File diff suppressed because it is too large
Load Diff
@@ -411,6 +411,16 @@ namespace pipedal
|
|||||||
return this->sampleRate;
|
return this->sampleRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() const override
|
||||||
|
{
|
||||||
|
return (uint32_t)this->captureChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() const override
|
||||||
|
{
|
||||||
|
return (uint32_t)this->playbackChannels;
|
||||||
|
}
|
||||||
|
|
||||||
JackServerSettings jackServerSettings;
|
JackServerSettings jackServerSettings;
|
||||||
|
|
||||||
std::string alsa_device_name;
|
std::string alsa_device_name;
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ namespace pipedal {
|
|||||||
|
|
||||||
virtual uint32_t GetSampleRate() = 0;
|
virtual uint32_t GetSampleRate() = 0;
|
||||||
|
|
||||||
|
/// Get the number of capture (input) channels the device actually provides.
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() const = 0;
|
||||||
|
|
||||||
|
/// Get the number of playback (output) channels the device actually provides.
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() const = 0;
|
||||||
|
|
||||||
virtual size_t GetMidiInputEventCount() = 0;
|
virtual size_t GetMidiInputEventCount() = 0;
|
||||||
virtual MidiEvent*GetMidiEvents() = 0;
|
virtual MidiEvent*GetMidiEvents() = 0;
|
||||||
|
|
||||||
|
|||||||
+80
-5
@@ -24,12 +24,14 @@
|
|||||||
#include <lv2/atom/atom.h>
|
#include <lv2/atom/atom.h>
|
||||||
#include "SchedulerPriority.hpp"
|
#include "SchedulerPriority.hpp"
|
||||||
#include "AlsaSequencer.hpp"
|
#include "AlsaSequencer.hpp"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
|
|
||||||
#include "Lv2Log.hpp"
|
#include "Lv2Log.hpp"
|
||||||
|
|
||||||
#include "SchedulerPriority.hpp"
|
#include "SchedulerPriority.hpp"
|
||||||
#include "JackDriver.hpp"
|
#include "JackDriver.hpp"
|
||||||
#include "AlsaDriver.hpp"
|
#include "AlsaDriver.hpp"
|
||||||
|
#include "PipeWireDriver.hpp"
|
||||||
#include "DummyAudioDriver.hpp"
|
#include "DummyAudioDriver.hpp"
|
||||||
#include "AtomConverter.hpp"
|
#include "AtomConverter.hpp"
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
@@ -481,6 +483,7 @@ private:
|
|||||||
Uris uris;
|
Uris uris;
|
||||||
|
|
||||||
std::unique_ptr<AudioDriver> audioDriver;
|
std::unique_ptr<AudioDriver> audioDriver;
|
||||||
|
std::string driverType_;
|
||||||
|
|
||||||
std::recursive_mutex mutex;
|
std::recursive_mutex mutex;
|
||||||
int64_t overrunGracePeriodSamples = 0;
|
int64_t overrunGracePeriodSamples = 0;
|
||||||
@@ -535,6 +538,10 @@ private:
|
|||||||
std::vector<std::shared_ptr<Lv2Pedalboard>> activePedalboards; // pedalboards that have been sent to the audio queue.
|
std::vector<std::shared_ptr<Lv2Pedalboard>> activePedalboards; // pedalboards that have been sent to the audio queue.
|
||||||
Lv2Pedalboard *realtimeActivePedalboard = nullptr;
|
Lv2Pedalboard *realtimeActivePedalboard = nullptr;
|
||||||
|
|
||||||
|
// Band-in-a-Box Mixer Engine
|
||||||
|
std::shared_ptr<MixerEngine> currentMixerEngine;
|
||||||
|
MixerEngine *realtimeActiveMixerEngine = nullptr;
|
||||||
|
|
||||||
uint32_t sampleRate = 0;
|
uint32_t sampleRate = 0;
|
||||||
uint64_t currentSample = 0;
|
uint64_t currentSample = 0;
|
||||||
|
|
||||||
@@ -1083,6 +1090,11 @@ private:
|
|||||||
{
|
{
|
||||||
OnSnapshotTriggered(5);
|
OnSnapshotTriggered(5);
|
||||||
}
|
}
|
||||||
|
else if (this->realtimeActiveMixerEngine != nullptr && (event.buffer[0] & 0xF0) == 0xB0)
|
||||||
|
{
|
||||||
|
// Route MIDI CC to mixer engine's control surface mapper
|
||||||
|
this->realtimeActiveMixerEngine->processMidiEvent(event);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ProcessMidiMonitor(eventBufferWriter, iterator, event);
|
ProcessMidiMonitor(eventBufferWriter, iterator, event);
|
||||||
@@ -1197,6 +1209,24 @@ private:
|
|||||||
|
|
||||||
PIPEDAL_NON_INLINE void ProcessLv2Pedalboard(size_t nframes)
|
PIPEDAL_NON_INLINE void ProcessLv2Pedalboard(size_t nframes)
|
||||||
{
|
{
|
||||||
|
// Band-in-a-Box Mixer Engine takes priority over legacy pedalboard
|
||||||
|
MixerEngine *mixerEngine = this->realtimeActiveMixerEngine;
|
||||||
|
if (mixerEngine != nullptr)
|
||||||
|
{
|
||||||
|
// Route through mixer engine using device input/output buffers
|
||||||
|
auto &driver = this->audioDriver;
|
||||||
|
if (driver) {
|
||||||
|
mixerEngine->process(
|
||||||
|
driver->DeviceInputBuffers().data(),
|
||||||
|
(uint32_t)driver->DeviceInputBufferCount(),
|
||||||
|
driver->DeviceOutputBuffers().data(),
|
||||||
|
(uint32_t)driver->DeviceOutputBufferCount(),
|
||||||
|
(uint32_t)nframes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Lv2Pedalboard *pedalboard = nullptr;
|
Lv2Pedalboard *pedalboard = nullptr;
|
||||||
|
|
||||||
std::vector<float *> *pInputBuffers;
|
std::vector<float *> *pInputBuffers;
|
||||||
@@ -1395,7 +1425,7 @@ private:
|
|||||||
}
|
}
|
||||||
bool processed = false;
|
bool processed = false;
|
||||||
|
|
||||||
if (pedalboard != nullptr)
|
if (pedalboard != nullptr || this->realtimeActiveMixerEngine != nullptr)
|
||||||
{
|
{
|
||||||
ProcessGlobalMidiInput();
|
ProcessGlobalMidiInput();
|
||||||
}
|
}
|
||||||
@@ -1421,7 +1451,7 @@ private:
|
|||||||
}
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
AudioHostImpl(IHost *pHost)
|
AudioHostImpl(IHost *pHost, const std::string &driverType)
|
||||||
: inputRingBuffer(RING_BUFFER_SIZE),
|
: inputRingBuffer(RING_BUFFER_SIZE),
|
||||||
outputRingBuffer(RING_BUFFER_SIZE),
|
outputRingBuffer(RING_BUFFER_SIZE),
|
||||||
realtimeReader(&this->inputRingBuffer),
|
realtimeReader(&this->inputRingBuffer),
|
||||||
@@ -1431,7 +1461,8 @@ public:
|
|||||||
eventBufferUrids(pHost),
|
eventBufferUrids(pHost),
|
||||||
pHost(pHost),
|
pHost(pHost),
|
||||||
uris(pHost),
|
uris(pHost),
|
||||||
atomConverter(pHost->GetMapFeature())
|
atomConverter(pHost->GetMapFeature()),
|
||||||
|
driverType_(driverType)
|
||||||
{
|
{
|
||||||
realtimeAtomBuffer.resize(32 * 1024);
|
realtimeAtomBuffer.resize(32 * 1024);
|
||||||
lv2_atom_forge_init(&inputWriterForge, pHost->GetMapFeature().GetMap());
|
lv2_atom_forge_init(&inputWriterForge, pHost->GetMapFeature().GetMap());
|
||||||
@@ -1470,6 +1501,25 @@ public:
|
|||||||
{
|
{
|
||||||
return this->sampleRate;
|
return this->sampleRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() override
|
||||||
|
{
|
||||||
|
if (this->audioDriver)
|
||||||
|
{
|
||||||
|
return this->audioDriver->GetDeviceCaptureChannels();
|
||||||
|
}
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() override
|
||||||
|
{
|
||||||
|
if (this->audioDriver)
|
||||||
|
{
|
||||||
|
return this->audioDriver->GetDevicePlaybackChannels();
|
||||||
|
}
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
void HandleAlsaSequencerDevicesChanged(
|
void HandleAlsaSequencerDevicesChanged(
|
||||||
AlsaSequencerDeviceMonitor::MonitorAction action, int client, const std::string &clientName)
|
AlsaSequencerDeviceMonitor::MonitorAction action, int client, const std::string &clientName)
|
||||||
{
|
{
|
||||||
@@ -1858,7 +1908,16 @@ public:
|
|||||||
isOpen = true;
|
isOpen = true;
|
||||||
|
|
||||||
this->isDummyAudioDriver = jackServerSettings.IsDummyAudioDevice();
|
this->isDummyAudioDriver = jackServerSettings.IsDummyAudioDevice();
|
||||||
|
|
||||||
|
if (driverType_ == "pipewire")
|
||||||
|
{
|
||||||
|
this->audioDriver = std::unique_ptr<AudioDriver>(CreatePipeWireDriver(this));
|
||||||
|
Lv2Log::info("Using PipeWire audio driver.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
this->audioDriver = std::unique_ptr<AudioDriver>(CreateAlsaDriver(this));
|
this->audioDriver = std::unique_ptr<AudioDriver>(CreateAlsaDriver(this));
|
||||||
|
}
|
||||||
|
|
||||||
this->currentSample = 0;
|
this->currentSample = 0;
|
||||||
this->underruns = 0;
|
this->underruns = 0;
|
||||||
@@ -1944,6 +2003,22 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
virtual void SetMixerEngine(const std::shared_ptr<MixerEngine> &mixerEngine)
|
||||||
|
{
|
||||||
|
std::lock_guard guard(mutex);
|
||||||
|
this->currentMixerEngine = mixerEngine;
|
||||||
|
if (active && mixerEngine)
|
||||||
|
{
|
||||||
|
// Activate the mixer engine and set it as the realtime processing target.
|
||||||
|
// The mixer engine takes over from the legacy pedalboard.
|
||||||
|
this->realtimeActiveMixerEngine = mixerEngine.get();
|
||||||
|
}
|
||||||
|
else if (!mixerEngine)
|
||||||
|
{
|
||||||
|
this->realtimeActiveMixerEngine = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
virtual void SetBypass(uint64_t instanceId, bool enabled)
|
virtual void SetBypass(uint64_t instanceId, bool enabled)
|
||||||
{
|
{
|
||||||
std::lock_guard guard(mutex);
|
std::lock_guard guard(mutex);
|
||||||
@@ -2458,9 +2533,9 @@ void AudioHostImpl::SetSystemMidiBindings(const std::vector<MidiBinding> &bindin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioHost *AudioHost::CreateInstance(IHost *pHost)
|
AudioHost *AudioHost::CreateInstance(IHost *pHost, const std::string &driverType)
|
||||||
{
|
{
|
||||||
return new AudioHostImpl(pHost);
|
return new AudioHostImpl(pHost, driverType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed because any updates to state have to be sent to clients as well,
|
// Removed because any updates to state have to be sent to clients as well,
|
||||||
|
|||||||
+13
-1
@@ -37,6 +37,8 @@
|
|||||||
namespace pipedal
|
namespace pipedal
|
||||||
{
|
{
|
||||||
|
|
||||||
|
class MixerEngine; // forward declaration for band-in-a-box mode
|
||||||
|
|
||||||
struct RealtimeMidiProgramRequest;
|
struct RealtimeMidiProgramRequest;
|
||||||
struct RealtimeNextMidiProgramRequest;
|
struct RealtimeNextMidiProgramRequest;
|
||||||
class PluginHost;
|
class PluginHost;
|
||||||
@@ -213,7 +215,7 @@ namespace pipedal
|
|||||||
AudioHost() {}
|
AudioHost() {}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
static AudioHost *CreateInstance(IHost *pHost);
|
static AudioHost *CreateInstance(IHost *pHost, const std::string &driverType = "alsa");
|
||||||
virtual ~AudioHost() {};
|
virtual ~AudioHost() {};
|
||||||
|
|
||||||
virtual void UpdateServerConfiguration(const JackServerSettings &jackServerSettings,
|
virtual void UpdateServerConfiguration(const JackServerSettings &jackServerSettings,
|
||||||
@@ -235,10 +237,20 @@ namespace pipedal
|
|||||||
virtual void SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) = 0;
|
virtual void SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) = 0;
|
||||||
virtual uint32_t GetSampleRate() = 0;
|
virtual uint32_t GetSampleRate() = 0;
|
||||||
|
|
||||||
|
/// Get the number of capture (input) channels the audio device provides.
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() = 0;
|
||||||
|
|
||||||
|
/// Get the number of playback (output) channels the audio device provides.
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() = 0;
|
||||||
|
|
||||||
virtual JackConfiguration GetServerConfiguration() = 0;
|
virtual JackConfiguration GetServerConfiguration() = 0;
|
||||||
|
|
||||||
virtual void SetPedalboard(const std::shared_ptr<Lv2Pedalboard> &pedalboard) = 0;
|
virtual void SetPedalboard(const std::shared_ptr<Lv2Pedalboard> &pedalboard) = 0;
|
||||||
|
|
||||||
|
/// Set the mixer engine for band-in-a-box mode.
|
||||||
|
/// When set, overrides the legacy pedalboard processing.
|
||||||
|
virtual void SetMixerEngine(const std::shared_ptr<MixerEngine> &mixerEngine) = 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
virtual void SetControlValue(uint64_t instanceId, const std::string &symbol, float value) = 0;
|
virtual void SetControlValue(uint64_t instanceId, const std::string &symbol, float value) = 0;
|
||||||
|
|||||||
@@ -366,9 +366,18 @@ set (PIPEDAL_SOURCES
|
|||||||
JackDriver.cpp JackDriver.hpp
|
JackDriver.cpp JackDriver.hpp
|
||||||
AlsaDriver.cpp AlsaDriver.hpp
|
AlsaDriver.cpp AlsaDriver.hpp
|
||||||
DummyAudioDriver.cpp DummyAudioDriver.hpp
|
DummyAudioDriver.cpp DummyAudioDriver.hpp
|
||||||
|
PipeWireDriver.cpp PipeWireDriver.hpp
|
||||||
AudioDriver.hpp
|
AudioDriver.hpp
|
||||||
AudioConfig.hpp
|
AudioConfig.hpp
|
||||||
|
|
||||||
|
# Mixer Engine (Band-in-a-Box)
|
||||||
|
MixerChannelStrip.cpp MixerChannelStrip.hpp
|
||||||
|
MixerBus.cpp MixerBus.hpp
|
||||||
|
MixerEngine.cpp MixerEngine.hpp
|
||||||
|
MixerApi.cpp MixerApi.hpp
|
||||||
|
MidiMapper.cpp MidiMapper.hpp
|
||||||
|
MidiLearnMode.cpp MidiLearnMode.hpp
|
||||||
|
|
||||||
${VST3_SOURCES}
|
${VST3_SOURCES}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ namespace pipedal
|
|||||||
return this->sampleRate;
|
return this->sampleRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() const override
|
||||||
|
{
|
||||||
|
return (uint32_t)deviceCaptureBuffers.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() const override
|
||||||
|
{
|
||||||
|
return (uint32_t)devicePlaybackBuffers.size();
|
||||||
|
}
|
||||||
|
|
||||||
JackServerSettings jackServerSettings;
|
JackServerSettings jackServerSettings;
|
||||||
AlsaSequencer::ptr alsaSequencer;
|
AlsaSequencer::ptr alsaSequencer;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,754 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "MixerApi.hpp"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
|
#include "MixerChannelStrip.hpp"
|
||||||
|
#include "MixerBus.hpp"
|
||||||
|
#include "json.hpp"
|
||||||
|
#include "json_variant.hpp"
|
||||||
|
#include "Lv2Log.hpp"
|
||||||
|
#include <sstream>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scene storage types (JSON-serializable, mapped via JSON_MAP macros)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
/// A single saved scene.
|
||||||
|
class SceneEntry {
|
||||||
|
public:
|
||||||
|
int64_t id_;
|
||||||
|
std::string name_;
|
||||||
|
raw_json_string state_; // raw mixer state JSON, stored unescaped
|
||||||
|
|
||||||
|
DECLARE_JSON_MAP(SceneEntry);
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Top-level scenes file structure.
|
||||||
|
class ScenesFile {
|
||||||
|
public:
|
||||||
|
int64_t nextId_ = 1;
|
||||||
|
std::vector<SceneEntry> scenes_;
|
||||||
|
|
||||||
|
DECLARE_JSON_MAP(ScenesFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
JSON_MAP_BEGIN(SceneEntry)
|
||||||
|
JSON_MAP_REFERENCE(SceneEntry, id)
|
||||||
|
JSON_MAP_REFERENCE(SceneEntry, name)
|
||||||
|
JSON_MAP_REFERENCE(SceneEntry, state)
|
||||||
|
JSON_MAP_END();
|
||||||
|
|
||||||
|
JSON_MAP_BEGIN(ScenesFile)
|
||||||
|
JSON_MAP_REFERENCE(ScenesFile, nextId)
|
||||||
|
JSON_MAP_REFERENCE(ScenesFile, scenes)
|
||||||
|
JSON_MAP_END();
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Resolve ~/op-pedal/default_config/ to an absolute path.
|
||||||
|
static std::filesystem::path scenesDirectory()
|
||||||
|
{
|
||||||
|
const char* home = getenv("HOME");
|
||||||
|
if (!home) home = "/home/oplabs";
|
||||||
|
return std::filesystem::path(home) / "op-pedal" / "default_config";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full path to scenes.json.
|
||||||
|
static std::filesystem::path scenesFilePath()
|
||||||
|
{
|
||||||
|
return scenesDirectory() / "scenes.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load scenes from disk. Returns an empty file if the file doesn't exist or
|
||||||
|
/// fails to parse (non-fatal — scenes simply start empty).
|
||||||
|
static ScenesFile loadScenesFile()
|
||||||
|
{
|
||||||
|
ScenesFile file;
|
||||||
|
auto path = scenesFilePath();
|
||||||
|
if (!std::filesystem::exists(path))
|
||||||
|
return file;
|
||||||
|
|
||||||
|
try {
|
||||||
|
std::ifstream s(path);
|
||||||
|
if (!s.is_open())
|
||||||
|
return file;
|
||||||
|
json_reader reader(s);
|
||||||
|
reader.read(&file);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Lv2Log::warning("Failed to load %s: %s", path.c_str(), e.what());
|
||||||
|
// Return empty file on parse failure
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save scenes to disk.
|
||||||
|
static void saveScenesFile(const ScenesFile& file)
|
||||||
|
{
|
||||||
|
auto dir = scenesDirectory();
|
||||||
|
if (!std::filesystem::exists(dir))
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
|
||||||
|
auto path = scenesFilePath();
|
||||||
|
std::ofstream s(path);
|
||||||
|
if (!s.is_open())
|
||||||
|
{
|
||||||
|
Lv2Log::error("Failed to write %s", path.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json_writer writer(s, false);
|
||||||
|
writer.write(file);
|
||||||
|
s.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MixerApi implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MixerApi::MixerApi()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerApi::~MixerApi()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelVolume(int channelIndex, float volumeDb)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) channel->setVolume(volumeDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelPan(int channelIndex, float pan)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) channel->setPan(pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelMute(int channelIndex, bool mute)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) channel->setMute(mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelSolo(int channelIndex, bool solo)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) channel->setSolo(solo);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelLabel(int channelIndex, const std::string& label)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) channel->setLabel(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelType(int channelIndex, const std::string& type)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
|
||||||
|
if (!channel) return;
|
||||||
|
if (type == "Instrument" || type == "instrument") {
|
||||||
|
channel->setChannelType(MixerChannelType::Instrument);
|
||||||
|
} else if (type == "Mic" || type == "mic") {
|
||||||
|
channel->setChannelType(MixerChannelType::Mic);
|
||||||
|
} else if (type == "Line" || type == "line") {
|
||||||
|
channel->setChannelType(MixerChannelType::Line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelHpf(int channelIndex, bool enabled, float frequency)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) {
|
||||||
|
channel->setHpEnabled(enabled);
|
||||||
|
if (frequency > 0) channel->setHpFrequency(frequency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int MixerApi::addChannel(int physicalInputIndex)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return -1;
|
||||||
|
auto* channel = mixerEngine_->addChannel(physicalInputIndex);
|
||||||
|
return channel ? channel->channelIndex() : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::removeChannel(int channelIndex)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->removeChannel(channelIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setBusVolume(int64_t busId, float volumeDb)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* bus = mixerEngine_->getBus(busId);
|
||||||
|
if (bus) bus->setVolume(volumeDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setBusMute(int64_t busId, bool mute)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* bus = mixerEngine_->getBus(busId);
|
||||||
|
if (bus) bus->setMute(mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t MixerApi::addBus(const std::string& type, const std::string& name, int channels)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return -1;
|
||||||
|
|
||||||
|
MixerBusType busType = MixerBusType::Subgroup;
|
||||||
|
if (type == "Master" || type == "master") busType = MixerBusType::Master;
|
||||||
|
else if (type == "Aux" || type == "aux") busType = MixerBusType::Aux;
|
||||||
|
else if (type == "FxReturn" || type == "fxreturn") busType = MixerBusType::FxReturn;
|
||||||
|
else if (type == "Subgroup" || type == "subgroup") busType = MixerBusType::Subgroup;
|
||||||
|
|
||||||
|
return mixerEngine_->addBus(busType, name, channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::removeBus(int64_t busId)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->removeBus(busId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::routeChannelToBus(int channelIndex, int64_t busId, float levelDb)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->routeChannelToBus(channelIndex, busId, levelDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::routeBusToBus(int64_t sourceBusId, int64_t targetBusId, float levelDb)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->routeBusToBus(sourceBusId, targetBusId, levelDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::removeRoute(int64_t sourceId, int64_t targetBusId)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->removeRoute(sourceId, targetBusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string MixerApi::getStateJson() const
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return "{}";
|
||||||
|
|
||||||
|
auto snapshot = mixerEngine_->captureSnapshot();
|
||||||
|
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss, false);
|
||||||
|
writer.start_object();
|
||||||
|
|
||||||
|
writer.write_member("channels", "");
|
||||||
|
// Overwrite the empty string with raw array
|
||||||
|
writer.write_raw("[");
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& cs : snapshot.channels) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("channelIndex", (int64_t)cs.channelIndex);
|
||||||
|
writer.write_member("volume", (double)cs.volume);
|
||||||
|
writer.write_member("pan", (double)cs.pan);
|
||||||
|
writer.write_member("mute", cs.mute);
|
||||||
|
writer.write_member("solo", cs.solo);
|
||||||
|
|
||||||
|
const char* typeStr = "Instrument";
|
||||||
|
switch (cs.channelType) {
|
||||||
|
case MixerChannelType::Mic: typeStr = "Mic"; break;
|
||||||
|
case MixerChannelType::Line: typeStr = "Line"; break;
|
||||||
|
case MixerChannelType::AuxReturn: typeStr = "AuxReturn"; break;
|
||||||
|
default: typeStr = "Instrument"; break;
|
||||||
|
}
|
||||||
|
writer.write_member("type", typeStr);
|
||||||
|
writer.write_member("label", cs.label);
|
||||||
|
writer.write_member("hpEnabled", cs.hpEnabled);
|
||||||
|
writer.write_member("hpFrequency", (double)cs.hpFrequency);
|
||||||
|
writer.write_member("vuLeft", (double)cs.vuLeft);
|
||||||
|
writer.write_member("vuRight", (double)cs.vuRight);
|
||||||
|
writer.write_member("gateOpen", cs.gateOpen);
|
||||||
|
writer.write_member("compressorReduction", (double)cs.compressorReduction);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
|
||||||
|
writer.write_member("buses", "");
|
||||||
|
writer.write_raw("[");
|
||||||
|
first = true;
|
||||||
|
for (const auto& bs : snapshot.buses) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("id", bs.id);
|
||||||
|
writer.write_member("name", bs.name);
|
||||||
|
|
||||||
|
const char* typeStr = "Subgroup";
|
||||||
|
switch (bs.type) {
|
||||||
|
case MixerBusType::Master: typeStr = "Master"; break;
|
||||||
|
case MixerBusType::Aux: typeStr = "Aux"; break;
|
||||||
|
case MixerBusType::FxReturn: typeStr = "FxReturn"; break;
|
||||||
|
default: typeStr = "Subgroup"; break;
|
||||||
|
}
|
||||||
|
writer.write_member("type", typeStr);
|
||||||
|
writer.write_member("volume", (double)bs.volume);
|
||||||
|
writer.write_member("mute", bs.mute);
|
||||||
|
writer.write_member("vuLeft", (double)bs.vuLeft);
|
||||||
|
writer.write_member("vuRight", (double)bs.vuRight);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
|
||||||
|
writer.write_member("routes", "");
|
||||||
|
writer.write_raw("[");
|
||||||
|
first = true;
|
||||||
|
for (const auto& route : snapshot.routes) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("sourceId", route.sourceId);
|
||||||
|
writer.write_member("targetBusId", route.targetBusId);
|
||||||
|
writer.write_member("level", (double)route.level);
|
||||||
|
|
||||||
|
const char* sourceType = (route.sourceType == MixerRouteEntry::SourceChannel) ? "channel" : "bus";
|
||||||
|
writer.write_member("sourceType", sourceType);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
|
||||||
|
// Output routing
|
||||||
|
writer.write_member("outputRoutes", "");
|
||||||
|
writer.write_raw("[");
|
||||||
|
first = true;
|
||||||
|
for (const auto& route : mixerEngine_->outputRoutes()) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("sourceBusId", route.sourceBusId);
|
||||||
|
writer.write_member("sourceStartChannel", (int64_t)route.sourceStartChannel);
|
||||||
|
writer.write_member("targetStartChannel", (int64_t)route.targetStartChannel);
|
||||||
|
writer.write_member("channels", (int64_t)route.channels);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
|
||||||
|
// Physical I/O info
|
||||||
|
writer.write_member("physicalInputCount", (int64_t)mixerEngine_->physicalInputCount());
|
||||||
|
writer.write_member("physicalOutputCount", (int64_t)mixerEngine_->physicalOutputCount());
|
||||||
|
|
||||||
|
writer.end_object();
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scene management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
std::string MixerApi::saveScene(const std::string& name)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) {
|
||||||
|
return "{\"error\":\"no mixer engine\"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture current mixer state
|
||||||
|
std::string stateJson = getStateJson();
|
||||||
|
|
||||||
|
// Load existing scenes
|
||||||
|
ScenesFile file = loadScenesFile();
|
||||||
|
|
||||||
|
// Create new scene
|
||||||
|
SceneEntry entry;
|
||||||
|
entry.id_ = file.nextId_++;
|
||||||
|
entry.name_ = name;
|
||||||
|
entry.state_ = raw_json_string(stateJson);
|
||||||
|
|
||||||
|
file.scenes_.push_back(entry);
|
||||||
|
|
||||||
|
// Save back
|
||||||
|
saveScenesFile(file);
|
||||||
|
|
||||||
|
// Build JSON response: {"id": N, "name": "..."}
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss, false);
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("id", entry.id_);
|
||||||
|
writer.write_member("name", entry.name_);
|
||||||
|
writer.end_object();
|
||||||
|
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MixerApi::loadScene(const std::string& sceneId)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return false;
|
||||||
|
|
||||||
|
// Try parsing as integer
|
||||||
|
int64_t targetId;
|
||||||
|
try {
|
||||||
|
targetId = std::stoll(sceneId);
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load scenes and find matching ID
|
||||||
|
ScenesFile file = loadScenesFile();
|
||||||
|
auto it = std::find_if(file.scenes_.begin(), file.scenes_.end(),
|
||||||
|
[targetId](const SceneEntry& e) { return e.id_ == targetId; });
|
||||||
|
|
||||||
|
if (it == file.scenes_.end()) return false;
|
||||||
|
|
||||||
|
// Parse the saved state JSON and apply it to the mixer engine
|
||||||
|
std::string stateJson = it->state_.as_string();
|
||||||
|
if (stateJson.empty()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse the saved state JSON and apply it to the mixer engine
|
||||||
|
std::istringstream ss(stateJson);
|
||||||
|
json_reader reader(ss);
|
||||||
|
|
||||||
|
// Manual JSON walk: {"channels": [...], "buses": [...], "routes": [...]}
|
||||||
|
reader.start_object(); // {
|
||||||
|
while (reader.peek() != '}')
|
||||||
|
{
|
||||||
|
std::string memberName = reader.read_string();
|
||||||
|
reader.consume(':');
|
||||||
|
|
||||||
|
if (memberName == "channels")
|
||||||
|
{
|
||||||
|
reader.consume('['); // start array
|
||||||
|
while (reader.peek() != ']')
|
||||||
|
{
|
||||||
|
int64_t channelIndex = 0;
|
||||||
|
double volume = 0.0;
|
||||||
|
double pan = 0.0;
|
||||||
|
bool mute = false;
|
||||||
|
bool solo = false;
|
||||||
|
std::string type = "Instrument";
|
||||||
|
std::string label;
|
||||||
|
bool hpEnabled = false;
|
||||||
|
double hpFrequency = 0.0;
|
||||||
|
|
||||||
|
reader.start_object(); // {
|
||||||
|
while (reader.peek() != '}')
|
||||||
|
{
|
||||||
|
std::string fieldName = reader.read_string();
|
||||||
|
reader.consume(':');
|
||||||
|
|
||||||
|
if (fieldName == "channelIndex") reader.read(&channelIndex);
|
||||||
|
else if (fieldName == "volume") reader.read(&volume);
|
||||||
|
else if (fieldName == "pan") reader.read(&pan);
|
||||||
|
else if (fieldName == "mute") reader.read(&mute);
|
||||||
|
else if (fieldName == "solo") reader.read(&solo);
|
||||||
|
else if (fieldName == "type") reader.read(&type);
|
||||||
|
else if (fieldName == "label") reader.read(&label);
|
||||||
|
else if (fieldName == "hpEnabled") reader.read(&hpEnabled);
|
||||||
|
else if (fieldName == "hpFrequency") reader.read(&hpFrequency);
|
||||||
|
else reader.skip_property();
|
||||||
|
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.end_object(); // }
|
||||||
|
|
||||||
|
// Apply to matching channel
|
||||||
|
auto* channel = mixerEngine_->getChannel((int)channelIndex);
|
||||||
|
if (channel) {
|
||||||
|
channel->setVolume((float)volume);
|
||||||
|
channel->setPan((float)pan);
|
||||||
|
channel->setMute(mute);
|
||||||
|
channel->setSolo(solo);
|
||||||
|
|
||||||
|
MixerChannelType ct = MixerChannelType::Instrument;
|
||||||
|
if (type == "Mic" || type == "mic") ct = MixerChannelType::Mic;
|
||||||
|
else if (type == "Line" || type == "line") ct = MixerChannelType::Line;
|
||||||
|
else if (type == "AuxReturn" || type == "auxreturn") ct = MixerChannelType::AuxReturn;
|
||||||
|
channel->setChannelType(ct);
|
||||||
|
|
||||||
|
channel->setLabel(label);
|
||||||
|
channel->setHpEnabled(hpEnabled);
|
||||||
|
if (hpFrequency > 0) channel->setHpFrequency((float)hpFrequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.consume(']'); // end array
|
||||||
|
}
|
||||||
|
else if (memberName == "buses")
|
||||||
|
{
|
||||||
|
reader.consume('['); // start array
|
||||||
|
while (reader.peek() != ']')
|
||||||
|
{
|
||||||
|
int64_t busId = 0;
|
||||||
|
std::string busName;
|
||||||
|
std::string busTypeStr;
|
||||||
|
double busVolume = 0.0;
|
||||||
|
bool busMute = false;
|
||||||
|
|
||||||
|
reader.start_object(); // {
|
||||||
|
while (reader.peek() != '}')
|
||||||
|
{
|
||||||
|
std::string fieldName = reader.read_string();
|
||||||
|
reader.consume(':');
|
||||||
|
|
||||||
|
if (fieldName == "id") reader.read(&busId);
|
||||||
|
else if (fieldName == "name") reader.read(&busName);
|
||||||
|
else if (fieldName == "type") reader.read(&busTypeStr);
|
||||||
|
else if (fieldName == "volume") reader.read(&busVolume);
|
||||||
|
else if (fieldName == "mute") reader.read(&busMute);
|
||||||
|
else reader.skip_property();
|
||||||
|
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.end_object(); // }
|
||||||
|
|
||||||
|
auto* bus = mixerEngine_->getBus(busId);
|
||||||
|
if (bus) {
|
||||||
|
bus->setVolume((float)busVolume);
|
||||||
|
bus->setMute(busMute);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.consume(']'); // end array
|
||||||
|
}
|
||||||
|
else if (memberName == "routes")
|
||||||
|
{
|
||||||
|
// Skip routes — they are structural and shouldn't be overwritten
|
||||||
|
// on scene load for safety.
|
||||||
|
reader.skip_property();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reader.skip_property();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.end_object(); // }
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Lv2Log::error("Failed to load scene %s: %s", sceneId.c_str(), e.what());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string MixerApi::listScenes() const
|
||||||
|
{
|
||||||
|
ScenesFile file = loadScenesFile();
|
||||||
|
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss, false);
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("scenes", "");
|
||||||
|
writer.write_raw("[");
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& entry : file.scenes_) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("id", entry.id_);
|
||||||
|
writer.write_member("name", entry.name_);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
writer.end_object();
|
||||||
|
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MixerApi::deleteScene(const std::string& sceneId)
|
||||||
|
{
|
||||||
|
int64_t targetId;
|
||||||
|
try {
|
||||||
|
targetId = std::stoll(sceneId);
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScenesFile file = loadScenesFile();
|
||||||
|
auto it = std::find_if(file.scenes_.begin(), file.scenes_.end(),
|
||||||
|
[targetId](const SceneEntry& e) { return e.id_ == targetId; });
|
||||||
|
|
||||||
|
if (it == file.scenes_.end()) return false;
|
||||||
|
|
||||||
|
file.scenes_.erase(it);
|
||||||
|
saveScenesFile(file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MIDI Control Surface Mapping ──────────────────────────────────────────
|
||||||
|
|
||||||
|
std::string MixerApi::getMidiMappingsJson() const
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return "[]";
|
||||||
|
return mixerEngine_->midiMapper().getMappingsJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setMidiMappingsFromJson(const std::string& json)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().setMappingsFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::addMidiMapping(int midiChannel, int ccNumber,
|
||||||
|
const std::string& targetType, int64_t targetId,
|
||||||
|
float minValue, float maxValue)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
MidiMappingEntry entry;
|
||||||
|
entry.midiChannel = midiChannel;
|
||||||
|
entry.ccNumber = ccNumber;
|
||||||
|
entry.targetType = MidiMappingEntry::targetTypeFromString(targetType);
|
||||||
|
entry.targetId = targetId;
|
||||||
|
entry.minValue = minValue;
|
||||||
|
entry.maxValue = maxValue;
|
||||||
|
mixerEngine_->midiMapper().addMapping(entry);
|
||||||
|
mixerEngine_->midiMapper().saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::removeMidiMapping(int midiChannel, int ccNumber)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().removeMapping(midiChannel, ccNumber);
|
||||||
|
mixerEngine_->midiMapper().saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::removeMidiMappingByIndex(size_t index)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().removeMappingByIndex(index);
|
||||||
|
mixerEngine_->midiMapper().saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::clearMidiMappings()
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().clearMappings();
|
||||||
|
mixerEngine_->midiMapper().saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setMidiLearnMode(bool enabled)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().setLearnMode(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MixerApi::getMidiLearnMode() const
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return false;
|
||||||
|
return mixerEngine_->midiMapper().learnMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setMidiLearnTarget(const std::string& targetType, int64_t targetId)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
MidiTargetType type = MidiMappingEntry::targetTypeFromString(targetType);
|
||||||
|
mixerEngine_->midiMapper().setPendingLearnTarget(type, targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MixerApi::commitMidiLearnMapping()
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return false;
|
||||||
|
return mixerEngine_->midiMapper().commitLearnMapping();
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerApi::LearnedEventInfo MixerApi::getLastLearnedMidiEvent() const
|
||||||
|
{
|
||||||
|
LearnedEventInfo info;
|
||||||
|
if (!mixerEngine_) return info;
|
||||||
|
info.hasEvent = mixerEngine_->midiMapper().getLastLearnedEvent(info.midiChannel, info.ccNumber);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::saveMidiMappingsToFile() const
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::loadMidiMappingsFromFile()
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().loadFromFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Output Routing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
std::string MixerApi::getOutputRoutesJson() const
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return "[]";
|
||||||
|
|
||||||
|
const auto& routes = mixerEngine_->outputRoutes();
|
||||||
|
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss, false);
|
||||||
|
writer.write_raw("[");
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& route : routes) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("sourceBusId", route.sourceBusId);
|
||||||
|
writer.write_member("sourceStartChannel", (int64_t)route.sourceStartChannel);
|
||||||
|
writer.write_member("targetStartChannel", (int64_t)route.targetStartChannel);
|
||||||
|
writer.write_member("channels", (int64_t)route.channels);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setOutputRoutesFromJson(const std::string& json)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
|
||||||
|
std::vector<MixerEngine::MixerOutputRoute> routes;
|
||||||
|
|
||||||
|
std::istringstream ss(json);
|
||||||
|
json_reader reader(ss);
|
||||||
|
reader.consume('[');
|
||||||
|
while (reader.peek() != ']')
|
||||||
|
{
|
||||||
|
MixerEngine::MixerOutputRoute route{};
|
||||||
|
reader.start_object();
|
||||||
|
while (reader.peek() != '}')
|
||||||
|
{
|
||||||
|
std::string key = reader.read_string();
|
||||||
|
reader.consume(':');
|
||||||
|
if (key == "sourceBusId") reader.read(&route.sourceBusId);
|
||||||
|
else if (key == "sourceStartChannel") { int64_t v; reader.read(&v); route.sourceStartChannel = (int)v; }
|
||||||
|
else if (key == "targetStartChannel") { int64_t v; reader.read(&v); route.targetStartChannel = (int)v; }
|
||||||
|
else if (key == "channels") { int64_t v; reader.read(&v); route.channels = (int)v; }
|
||||||
|
else reader.skip_property();
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.end_object();
|
||||||
|
routes.push_back(route);
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.consume(']');
|
||||||
|
|
||||||
|
mixerEngine_->setOutputRoutes(routes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <memory>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
class MixerEngine;
|
||||||
|
|
||||||
|
/// Mixer API — bridges WebSocket messages to the MixerEngine.
|
||||||
|
///
|
||||||
|
/// Follows the existing PiPedalSocket pattern where handlers are registered
|
||||||
|
/// via REGISTER_MESSAGE_HANDLER and dispatched by message name.
|
||||||
|
///
|
||||||
|
/// This class provides the model-level methods that the socket handlers call.
|
||||||
|
class MixerApi {
|
||||||
|
public:
|
||||||
|
MixerApi();
|
||||||
|
~MixerApi();
|
||||||
|
|
||||||
|
/// Set the mixer engine this API talks to.
|
||||||
|
void setMixerEngine(MixerEngine* engine) { mixerEngine_ = engine; }
|
||||||
|
MixerEngine* mixerEngine() const { return mixerEngine_; }
|
||||||
|
|
||||||
|
/// --- Channel Control ---
|
||||||
|
|
||||||
|
/// Set channel volume in dB (-inf to +12)
|
||||||
|
void setChannelVolume(int channelIndex, float volumeDb);
|
||||||
|
|
||||||
|
/// Set channel pan (-1.0 left to +1.0 right)
|
||||||
|
void setChannelPan(int channelIndex, float pan);
|
||||||
|
|
||||||
|
/// Set channel mute state
|
||||||
|
void setChannelMute(int channelIndex, bool mute);
|
||||||
|
|
||||||
|
/// Set channel solo state
|
||||||
|
void setChannelSolo(int channelIndex, bool solo);
|
||||||
|
|
||||||
|
/// Set channel label
|
||||||
|
void setChannelLabel(int channelIndex, const std::string& label);
|
||||||
|
|
||||||
|
/// Set channel type (Instrument, Mic, Line)
|
||||||
|
void setChannelType(int channelIndex, const std::string& type);
|
||||||
|
|
||||||
|
/// Set channel HPF state
|
||||||
|
void setChannelHpf(int channelIndex, bool enabled, float frequency);
|
||||||
|
|
||||||
|
/// --- Channel Lifecycle ---
|
||||||
|
|
||||||
|
/// Add a new channel for the given physical input
|
||||||
|
int addChannel(int physicalInputIndex);
|
||||||
|
|
||||||
|
/// Remove a channel
|
||||||
|
void removeChannel(int channelIndex);
|
||||||
|
|
||||||
|
/// --- Bus Control ---
|
||||||
|
|
||||||
|
/// Set bus volume
|
||||||
|
void setBusVolume(int64_t busId, float volumeDb);
|
||||||
|
|
||||||
|
/// Set bus mute
|
||||||
|
void setBusMute(int64_t busId, bool mute);
|
||||||
|
|
||||||
|
/// Add a new bus
|
||||||
|
int64_t addBus(const std::string& type, const std::string& name, int channels);
|
||||||
|
|
||||||
|
/// Remove a bus
|
||||||
|
void removeBus(int64_t busId);
|
||||||
|
|
||||||
|
/// --- Routing ---
|
||||||
|
|
||||||
|
/// Route a channel to a bus
|
||||||
|
void routeChannelToBus(int channelIndex, int64_t busId, float levelDb);
|
||||||
|
|
||||||
|
/// Route a bus to another bus
|
||||||
|
void routeBusToBus(int64_t sourceBusId, int64_t targetBusId, float levelDb);
|
||||||
|
|
||||||
|
/// Remove a route
|
||||||
|
void removeRoute(int64_t sourceId, int64_t targetBusId);
|
||||||
|
|
||||||
|
/// --- State Queries ---
|
||||||
|
|
||||||
|
/// Get the full mixer state as a JSON string
|
||||||
|
std::string getStateJson() const;
|
||||||
|
|
||||||
|
/// --- Output Routing ---
|
||||||
|
|
||||||
|
/// Get output routes as a JSON array string
|
||||||
|
std::string getOutputRoutesJson() const;
|
||||||
|
|
||||||
|
/// Set output routes from a JSON array string
|
||||||
|
void setOutputRoutesFromJson(const std::string& json);
|
||||||
|
|
||||||
|
/// Apply a full mixer state from a JSON string
|
||||||
|
void setFullState(const std::string& stateJson);
|
||||||
|
|
||||||
|
/// --- Scenes ---
|
||||||
|
|
||||||
|
/// Save current mixer state as a scene
|
||||||
|
std::string saveScene(const std::string& name);
|
||||||
|
|
||||||
|
/// Load a scene by name
|
||||||
|
bool loadScene(const std::string& sceneId);
|
||||||
|
|
||||||
|
/// List available scenes
|
||||||
|
std::string listScenes() const;
|
||||||
|
|
||||||
|
/// Delete a scene
|
||||||
|
bool deleteScene(const std::string& sceneId);
|
||||||
|
|
||||||
|
/// --- MIDI Control Surface Mapping ---
|
||||||
|
|
||||||
|
/// Get all MIDI mappings as JSON
|
||||||
|
std::string getMidiMappingsJson() const;
|
||||||
|
|
||||||
|
/// Set all MIDI mappings from JSON
|
||||||
|
void setMidiMappingsFromJson(const std::string& json);
|
||||||
|
|
||||||
|
/// Add a single MIDI mapping
|
||||||
|
void addMidiMapping(int midiChannel, int ccNumber,
|
||||||
|
const std::string& targetType, int64_t targetId,
|
||||||
|
float minValue, float maxValue);
|
||||||
|
|
||||||
|
/// Remove MIDI mapping by CC and channel
|
||||||
|
void removeMidiMapping(int midiChannel, int ccNumber);
|
||||||
|
|
||||||
|
/// Remove MIDI mapping by index
|
||||||
|
void removeMidiMappingByIndex(size_t index);
|
||||||
|
|
||||||
|
/// Clear all MIDI mappings
|
||||||
|
void clearMidiMappings();
|
||||||
|
|
||||||
|
/// Toggle MIDI learn mode
|
||||||
|
void setMidiLearnMode(bool enabled);
|
||||||
|
bool getMidiLearnMode() const;
|
||||||
|
|
||||||
|
/// Set the pending learn target (which UI control was touched)
|
||||||
|
void setMidiLearnTarget(const std::string& targetType, int64_t targetId);
|
||||||
|
|
||||||
|
/// Commit a learned mapping (captured CC + pending target)
|
||||||
|
bool commitMidiLearnMapping();
|
||||||
|
|
||||||
|
/// Get last learned CC event info (for UI feedback)
|
||||||
|
struct LearnedEventInfo {
|
||||||
|
bool hasEvent = false;
|
||||||
|
int midiChannel = -1;
|
||||||
|
int ccNumber = -1;
|
||||||
|
};
|
||||||
|
LearnedEventInfo getLastLearnedMidiEvent() const;
|
||||||
|
|
||||||
|
/// Save MIDI mappings to config file
|
||||||
|
void saveMidiMappingsToFile() const;
|
||||||
|
|
||||||
|
/// Load MIDI mappings from config file
|
||||||
|
void loadMidiMappingsFromFile();
|
||||||
|
|
||||||
|
private:
|
||||||
|
MixerEngine* mixerEngine_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "MixerBus.hpp"
|
||||||
|
#include <cmath>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
MixerBus::MixerBus(int64_t id, MixerBusType type, const std::string& name, int channels)
|
||||||
|
: id_(id)
|
||||||
|
, type_(type)
|
||||||
|
, name_(name)
|
||||||
|
, channelCount_(channels)
|
||||||
|
{
|
||||||
|
buffers_.resize(channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::setVolume(float db)
|
||||||
|
{
|
||||||
|
volume_ = std::clamp(db, -96.0f, 12.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::setMute(bool mute)
|
||||||
|
{
|
||||||
|
mute_ = mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::allocateBuffers(size_t maxFrames)
|
||||||
|
{
|
||||||
|
maxFrames_ = maxFrames;
|
||||||
|
for (auto& buf : buffers_) {
|
||||||
|
buf.resize(maxFrames, 0.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::clear()
|
||||||
|
{
|
||||||
|
for (auto& buf : buffers_) {
|
||||||
|
std::fill(buf.begin(), buf.end(), 0.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::accumulate(
|
||||||
|
const float* const* source,
|
||||||
|
uint32_t frames,
|
||||||
|
float gain,
|
||||||
|
int sourceChannels)
|
||||||
|
{
|
||||||
|
uint32_t n = std::min(frames, (uint32_t)maxFrames_);
|
||||||
|
int nChannels = std::min(sourceChannels, channelCount_);
|
||||||
|
|
||||||
|
if (std::abs(gain) < 0.0001f) return;
|
||||||
|
|
||||||
|
if (std::abs(gain - 1.0f) < 0.0001f) {
|
||||||
|
// Unity gain fast path
|
||||||
|
for (int ch = 0; ch < nChannels; ++ch) {
|
||||||
|
if (ch < (int)buffers_.size() && ch < sourceChannels && source[ch]) {
|
||||||
|
float* dst = buffers_[ch].data();
|
||||||
|
const float* src = source[ch];
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
dst[i] += src[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Scaled accumulation
|
||||||
|
for (int ch = 0; ch < nChannels; ++ch) {
|
||||||
|
if (ch < (int)buffers_.size() && ch < sourceChannels && source[ch]) {
|
||||||
|
float* dst = buffers_[ch].data();
|
||||||
|
const float* src = source[ch];
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
dst[i] += src[i] * gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::accumulateMono(
|
||||||
|
const float* source,
|
||||||
|
uint32_t frames,
|
||||||
|
float gain)
|
||||||
|
{
|
||||||
|
if (!source || buffers_.empty()) return;
|
||||||
|
|
||||||
|
uint32_t n = std::min(frames, (uint32_t)maxFrames_);
|
||||||
|
float* dst = buffers_[0].data();
|
||||||
|
|
||||||
|
if (std::abs(gain) < 0.0001f) return;
|
||||||
|
|
||||||
|
if (std::abs(gain - 1.0f) < 0.0001f) {
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
dst[i] += source[i];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
dst[i] += source[i] * gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::process(uint32_t frames)
|
||||||
|
{
|
||||||
|
uint32_t n = std::min(frames, (uint32_t)maxFrames_);
|
||||||
|
bool isMuted = mute_.load();
|
||||||
|
float volumeGain = isMuted ? 0.0f : std::pow(10.0f, volume_.load() / 20.0f);
|
||||||
|
|
||||||
|
// Peak VU tracking
|
||||||
|
float leftPeak = -96.0f;
|
||||||
|
float rightPeak = -96.0f;
|
||||||
|
|
||||||
|
if (std::abs(volumeGain) < 0.0001f) {
|
||||||
|
// Effectively mute
|
||||||
|
for (int ch = 0; ch < channelCount_; ++ch) {
|
||||||
|
if (ch < (int)buffers_.size()) {
|
||||||
|
std::fill(buffers_[ch].begin(), buffers_[ch].begin() + n, 0.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (std::abs(volumeGain - 1.0f) < 0.001f) {
|
||||||
|
// Unity gain — no scaling needed, just compute VU
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
if (buffers_.size() > 0) {
|
||||||
|
float absVal = std::abs(buffers_[0][i]);
|
||||||
|
if (absVal > leftPeak) leftPeak = absVal;
|
||||||
|
}
|
||||||
|
if (buffers_.size() > 1) {
|
||||||
|
float absVal = std::abs(buffers_[1][i]);
|
||||||
|
if (absVal > rightPeak) rightPeak = absVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Apply volume gain
|
||||||
|
for (int ch = 0; ch < channelCount_; ++ch) {
|
||||||
|
if (ch >= (int)buffers_.size()) break;
|
||||||
|
float* buf = buffers_[ch].data();
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
buf[i] *= volumeGain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Compute VU from scaled signal
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
if (buffers_.size() > 0) {
|
||||||
|
float absVal = std::abs(buffers_[0][i]);
|
||||||
|
if (absVal > leftPeak) leftPeak = absVal;
|
||||||
|
}
|
||||||
|
if (buffers_.size() > 1) {
|
||||||
|
float absVal = std::abs(buffers_[1][i]);
|
||||||
|
if (absVal > rightPeak) rightPeak = absVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert peak to dB with decay
|
||||||
|
float leftDb = (leftPeak > 0.00001f) ? 20.0f * std::log10(leftPeak) : -96.0f;
|
||||||
|
float rightDb = (rightPeak > 0.00001f) ? 20.0f * std::log10(rightPeak) : -96.0f;
|
||||||
|
|
||||||
|
float oldLeft = vuLeft_.load();
|
||||||
|
float oldRight = vuRight_.load();
|
||||||
|
|
||||||
|
if (leftDb > oldLeft) {
|
||||||
|
vuLeft_ = leftDb;
|
||||||
|
} else {
|
||||||
|
vuLeft_ = oldLeft * 0.95f + leftDb * 0.05f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightDb > oldRight) {
|
||||||
|
vuRight_ = rightDb;
|
||||||
|
} else {
|
||||||
|
vuRight_ = oldRight * 0.95f + rightDb * 0.05f;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <memory>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
/// Types of buses in the mixer architecture.
|
||||||
|
enum class MixerBusType {
|
||||||
|
Master, // Main L/R output — end of signal chain
|
||||||
|
Subgroup, // Named subgroup (Drums, Guitars, Vocals...)
|
||||||
|
Aux, // Aux send bus (monitor mix or FX send)
|
||||||
|
FxReturn, // Stereo return from a shared FX processor (reverb, delay)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// An audio bus that accumulates contributions from multiple sources.
|
||||||
|
///
|
||||||
|
/// Buses form the mixing topology:
|
||||||
|
/// Channels → subgroups → master
|
||||||
|
/// Channels → aux sends → aux buses (monitor mixes)
|
||||||
|
/// Aux buses → FxReturn buses → subgroup or master
|
||||||
|
///
|
||||||
|
/// Key design decisions:
|
||||||
|
/// - Buses are flat accumulators: they sum incoming audio with gain
|
||||||
|
/// - Bus processing is minimal (volume, mute only)
|
||||||
|
/// - A bus can be fed INTO another bus via the routing graph
|
||||||
|
/// - All audio is floating-point, 32-bit
|
||||||
|
class MixerBus {
|
||||||
|
public:
|
||||||
|
MixerBus(int64_t id, MixerBusType type, const std::string& name, int channels = 2);
|
||||||
|
~MixerBus() = default;
|
||||||
|
|
||||||
|
/// Bus identity
|
||||||
|
int64_t id() const { return id_; }
|
||||||
|
MixerBusType type() const { return type_; }
|
||||||
|
const std::string& name() const { return name_; }
|
||||||
|
void setName(const std::string& name) { name_ = name; }
|
||||||
|
|
||||||
|
/// Channel count (1 = mono, 2 = stereo, N = multi-channel)
|
||||||
|
int channelCount() const { return channelCount_; }
|
||||||
|
|
||||||
|
/// --- Control surface (atomic for RT-safe writes) ---
|
||||||
|
|
||||||
|
/// Master volume in dB (-inf to +12.0)
|
||||||
|
float volume() const { return volume_.load(); }
|
||||||
|
void setVolume(float db);
|
||||||
|
|
||||||
|
/// Mute
|
||||||
|
bool mute() const { return mute_.load(); }
|
||||||
|
void setMute(bool mute);
|
||||||
|
|
||||||
|
/// --- Audio buffers ---
|
||||||
|
|
||||||
|
/// Allocate internal buffers. Must be called before processing.
|
||||||
|
void allocateBuffers(size_t maxFrames);
|
||||||
|
|
||||||
|
/// Get read/write pointer to internal buffer for a channel
|
||||||
|
float* buffer(int channel) {
|
||||||
|
if (channel >= 0 && channel < (int)buffers_.size())
|
||||||
|
return buffers_[channel].data();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const float* buffer(int channel) const {
|
||||||
|
if (channel >= 0 && channel < (int)buffers_.size())
|
||||||
|
return buffers_[channel].data();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulate (sum) audio from a source into this bus with gain.
|
||||||
|
/// Performs: bus[ch][i] += source[ch][i] * gain for all channels
|
||||||
|
void accumulate(
|
||||||
|
const float* const* source,
|
||||||
|
uint32_t frames,
|
||||||
|
float gain,
|
||||||
|
int sourceChannels
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Same as accumulate but for a single interleaved source buffer
|
||||||
|
void accumulateMono(
|
||||||
|
const float* source,
|
||||||
|
uint32_t frames,
|
||||||
|
float gain
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Clear all bus buffers to zero (must be called at start of each cycle)
|
||||||
|
void clear();
|
||||||
|
|
||||||
|
/// Apply bus-level processing (volume, mute) to the internal mix.
|
||||||
|
/// Reads internal mix buffer, applies gain, writes back.
|
||||||
|
void process(uint32_t frames);
|
||||||
|
|
||||||
|
/// VU meter values after processing
|
||||||
|
float vuLeft() const { return vuLeft_.load(); }
|
||||||
|
float vuRight() const { return vuRight_.load(); }
|
||||||
|
|
||||||
|
/// Max frames this bus can handle
|
||||||
|
size_t maxFrames() const { return maxFrames_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
int64_t id_;
|
||||||
|
MixerBusType type_;
|
||||||
|
std::string name_;
|
||||||
|
int channelCount_;
|
||||||
|
|
||||||
|
std::atomic<float> volume_{0.0f}; // dB
|
||||||
|
std::atomic<bool> mute_{false};
|
||||||
|
|
||||||
|
// Internal accumulation buffers [channel][sample]
|
||||||
|
std::vector<std::vector<float>> buffers_;
|
||||||
|
|
||||||
|
// VU tracking
|
||||||
|
std::atomic<float> vuLeft_{-96.0f};
|
||||||
|
std::atomic<float> vuRight_{-96.0f};
|
||||||
|
|
||||||
|
size_t maxFrames_ = 512;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "MixerChannelStrip.hpp"
|
||||||
|
#include "Lv2Effect.hpp"
|
||||||
|
#include "PiPedalMath.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
std::atomic<int64_t> MixerChannelStrip::nextInstanceId_{1};
|
||||||
|
|
||||||
|
MixerChannelStrip::MixerChannelStrip(int channelIndex)
|
||||||
|
: channelIndex_(channelIndex)
|
||||||
|
, instanceId_(nextInstanceId_++)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerChannelStrip::~MixerChannelStrip()
|
||||||
|
{
|
||||||
|
Unprepare();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setVolume(float db)
|
||||||
|
{
|
||||||
|
volume_ = std::clamp(db, -96.0f, 12.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setPan(float pan)
|
||||||
|
{
|
||||||
|
pan_ = std::clamp(pan, -1.0f, 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setMute(bool mute)
|
||||||
|
{
|
||||||
|
mute_ = mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setSolo(bool solo)
|
||||||
|
{
|
||||||
|
solo_ = solo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setAuxSend(int index, const AuxSendConfig& config)
|
||||||
|
{
|
||||||
|
if (index >= 0 && index < (int)auxSends_.size()) {
|
||||||
|
auxSends_[index] = config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuxSendConfig& MixerChannelStrip::auxSend(int index) const
|
||||||
|
{
|
||||||
|
static const AuxSendConfig kDefault;
|
||||||
|
if (index >= 0 && index < (int)auxSends_.size()) {
|
||||||
|
return auxSends_[index];
|
||||||
|
}
|
||||||
|
return kDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::resizeAuxSends(size_t count)
|
||||||
|
{
|
||||||
|
auxSends_.resize(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setSampleRate(uint32_t sampleRate)
|
||||||
|
{
|
||||||
|
sampleRate_ = sampleRate;
|
||||||
|
hpfStates_.resize(2); // stereo HPF states
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setMaxBufferSize(size_t frames)
|
||||||
|
{
|
||||||
|
maxBufferSize_ = frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::prepareFx(IHost* pHost, Lv2PedalboardErrorList& errorList,
|
||||||
|
ExistingEffectMap* existingEffects)
|
||||||
|
{
|
||||||
|
// Create or re-create the Lv2Pedalboard for this channel's FX chain
|
||||||
|
if (!fxProcessor_) {
|
||||||
|
fxProcessor_ = std::make_unique<Lv2Pedalboard>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate pre/post FX buffers (stereo, up to max buffer size)
|
||||||
|
preFxBuffers_.clear();
|
||||||
|
postFxBuffers_.clear();
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
preFxBuffers_.emplace_back(maxBufferSize_, 0.0f);
|
||||||
|
postFxBuffers_.emplace_back(maxBufferSize_, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the FX processor with this channel's pedalboard
|
||||||
|
fxProcessor_->Prepare(pHost, fxChain_, errorList, existingEffects);
|
||||||
|
fxProcessor_->Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
float MixerChannelStrip::effectiveAuxLevel(int auxIndex, bool anySoloActive) const
|
||||||
|
{
|
||||||
|
if (auxIndex < 0 || auxIndex >= (int)auxSends_.size()) return -96.0f;
|
||||||
|
|
||||||
|
const auto& send = auxSends_[auxIndex];
|
||||||
|
if (!send.isActive()) return -96.0f;
|
||||||
|
|
||||||
|
// Solo overrides: if any solo is active, only soloed channels are audible
|
||||||
|
if (anySoloActive && !solo_) return -96.0f;
|
||||||
|
if (mute_) return -96.0f;
|
||||||
|
|
||||||
|
return send.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::applyPan(float& leftGain, float& rightGain) const
|
||||||
|
{
|
||||||
|
float pan = pan_;
|
||||||
|
// Constant-power pan law: -3dB at center
|
||||||
|
// sin/cos distribution: L = cos(pan * PI/4), R = sin(pan * PI/4)
|
||||||
|
// Normalized so center = -3dB each
|
||||||
|
float angle = (pan * 0.5f + 0.5f) * (M_PI * 0.5f); // map -1..1 to 0..PI/2
|
||||||
|
leftGain = std::cos(angle);
|
||||||
|
rightGain = std::sin(angle);
|
||||||
|
|
||||||
|
// Compensate for equal-power pan: center should sum to unity
|
||||||
|
// Already handled by sin/cos distribution
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::applyHpf(float* buffer, uint32_t frames, HpfState& state)
|
||||||
|
{
|
||||||
|
if (!hpEnabled_) return;
|
||||||
|
|
||||||
|
// Simple 1st-order IIR HPF: y[n] = 0.5 * (x[n] - x[n-1] + y[n-1])
|
||||||
|
// Cutoff ~ 80Hz at 48kHz. For sharper roll-off, use biquad.
|
||||||
|
// This is intentionally simple for real-time safety.
|
||||||
|
float fc = hpFrequency_ / sampleRate_;
|
||||||
|
float alpha = fc / (fc + 0.5f); // approximation: R = 1/(2*PI*fc)
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < frames; ++i) {
|
||||||
|
float x = buffer[i];
|
||||||
|
float y = alpha * (state.y1 + x - state.x1);
|
||||||
|
state.x1 = x;
|
||||||
|
state.y1 = y;
|
||||||
|
buffer[i] = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::process(
|
||||||
|
const float* const* inputBuffers,
|
||||||
|
size_t inputChannels,
|
||||||
|
float* const* outputBuffers,
|
||||||
|
size_t outputChannels,
|
||||||
|
uint32_t frames)
|
||||||
|
{
|
||||||
|
// Clamp frames to allocated buffer size
|
||||||
|
frames = std::min(frames, (uint32_t)maxBufferSize_);
|
||||||
|
|
||||||
|
// Step 1: Copy input to pre-FX buffers and apply HPF
|
||||||
|
for (size_t ch = 0; ch < std::min(inputChannels, (size_t)2); ++ch) {
|
||||||
|
if (ch < preFxBuffers_.size() && inputBuffers[ch]) {
|
||||||
|
std::copy(inputBuffers[ch], inputBuffers[ch] + frames,
|
||||||
|
preFxBuffers_[ch].begin());
|
||||||
|
applyHpf(preFxBuffers_[ch].data(), frames,
|
||||||
|
ch < hpfStates_.size() ? hpfStates_[ch] : hpfStates_[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Run the FX chain (processes preFxBuffers_ -> postFxBuffers_)
|
||||||
|
if (fxProcessor_) {
|
||||||
|
// Build float* arrays for Lv2Pedalboard::Run
|
||||||
|
float* fxInputs[2];
|
||||||
|
float* fxOutputs[2];
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
fxInputs[i] = i < (int)preFxBuffers_.size() ? preFxBuffers_[i].data() : nullptr;
|
||||||
|
fxOutputs[i] = i < (int)postFxBuffers_.size() ? postFxBuffers_[i].data() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the FX chain (Lv2Pedalboard manages its internal routing)
|
||||||
|
fxProcessor_->Run(
|
||||||
|
(float**)fxInputs,
|
||||||
|
(float**)fxOutputs,
|
||||||
|
frames,
|
||||||
|
nullptr // no realtime ring buffer writer for now
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No FX chain — passthrough pre to post
|
||||||
|
for (size_t ch = 0; ch < std::min(inputChannels, (size_t)2); ++ch) {
|
||||||
|
if (ch < postFxBuffers_.size() && ch < preFxBuffers_.size()) {
|
||||||
|
std::copy(preFxBuffers_[ch].begin(),
|
||||||
|
preFxBuffers_[ch].begin() + frames,
|
||||||
|
postFxBuffers_[ch].begin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Apply volume, pan, and mute/solo to create output
|
||||||
|
bool isMuted = mute_.load();
|
||||||
|
bool isSoloed = solo_.load();
|
||||||
|
|
||||||
|
// Calculate gain from volume dB
|
||||||
|
float volumeGain = isMuted ? 0.0f : std::pow(10.0f, volume_.load() / 20.0f);
|
||||||
|
|
||||||
|
// Calculate pan gains
|
||||||
|
float leftGain = 1.0f, rightGain = 1.0f;
|
||||||
|
applyPan(leftGain, rightGain);
|
||||||
|
|
||||||
|
// Apply to output buffers
|
||||||
|
for (size_t outCh = 0; outCh < std::min(outputChannels, (size_t)2); ++outCh) {
|
||||||
|
if (!outputBuffers[outCh]) continue;
|
||||||
|
|
||||||
|
float* dst = outputBuffers[outCh];
|
||||||
|
const float* src = (outCh < postFxBuffers_.size())
|
||||||
|
? postFxBuffers_[outCh].data()
|
||||||
|
: (postFxBuffers_.empty() ? nullptr : postFxBuffers_[0].data());
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
std::fill(dst, dst + frames, 0.0f);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float panGain = (outCh == 0) ? leftGain : rightGain;
|
||||||
|
float finalGain = volumeGain * panGain;
|
||||||
|
|
||||||
|
if (finalGain < 0.001f) {
|
||||||
|
std::fill(dst, dst + frames, 0.0f);
|
||||||
|
} else if (std::abs(finalGain - 1.0f) < 0.001f) {
|
||||||
|
std::copy(src, src + frames, dst);
|
||||||
|
} else {
|
||||||
|
for (uint32_t i = 0; i < frames; ++i) {
|
||||||
|
dst[i] = src[i] * finalGain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Update VU meters (peak, with 300ms decay)
|
||||||
|
for (size_t ch = 0; ch < std::min(outputChannels, (size_t)2); ++ch) {
|
||||||
|
if (ch >= postFxBuffers_.size()) break;
|
||||||
|
|
||||||
|
float peak = 0.0f;
|
||||||
|
const float* buf = postFxBuffers_[ch].data();
|
||||||
|
for (uint32_t i = 0; i < frames; ++i) {
|
||||||
|
float absVal = std::abs(buf[i]);
|
||||||
|
if (absVal > peak) peak = absVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
float peakDb = (peak > 0.00001f) ? 20.0f * std::log10(peak) : -96.0f;
|
||||||
|
|
||||||
|
// Decay: 300ms time constant
|
||||||
|
float& vu = (ch == 0) ? vuLeft_ : vuRight_;
|
||||||
|
if (peakDb > vu) {
|
||||||
|
vu = peakDb; // Instant attack
|
||||||
|
} else {
|
||||||
|
// Decay at ~300ms: releaseRate = exp(-1 / (0.3 * sampleRate / frames))
|
||||||
|
static const float releaseRate = 0.95f;
|
||||||
|
vu = vu * releaseRate + peakDb * (1.0f - releaseRate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::Activate()
|
||||||
|
{
|
||||||
|
if (fxProcessor_) {
|
||||||
|
fxProcessor_->Activate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::Deactivate()
|
||||||
|
{
|
||||||
|
if (fxProcessor_) {
|
||||||
|
fxProcessor_->Deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::Unprepare()
|
||||||
|
{
|
||||||
|
if (fxProcessor_) {
|
||||||
|
fxProcessor_->Deactivate();
|
||||||
|
fxProcessor_.reset();
|
||||||
|
}
|
||||||
|
preFxBuffers_.clear();
|
||||||
|
postFxBuffers_.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
// this software and associated documentation files (the "Software"), to deal in
|
||||||
|
// the Software without restriction, including without limitation the rights to
|
||||||
|
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
// of the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
// subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Pedalboard.hpp"
|
||||||
|
#include "Lv2Pedalboard.hpp"
|
||||||
|
#include "IEffect.hpp"
|
||||||
|
#include "BufferPool.hpp"
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
#include <atomic>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
/// Channel type classification for mixer channel strips.
|
||||||
|
enum class MixerChannelType {
|
||||||
|
Instrument, // guitar, bass, keys — expects NAM/guitar amp modeling
|
||||||
|
Mic, // vocal mic — expects compressor, EQ, reverb chain
|
||||||
|
Line, // line-level input (backing tracks, synths, drum machines)
|
||||||
|
AuxReturn, // return from external FX processor
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Configuration for a single aux send on a channel strip.
|
||||||
|
struct AuxSendConfig {
|
||||||
|
float level = -96.0f; // dB, -96 ≈ -inf (effectively off)
|
||||||
|
bool preFader = false; // true = pre-fader (monitor send), false = post-fader (FX send)
|
||||||
|
int64_t targetBusId = -1; // bus ID this sends to
|
||||||
|
|
||||||
|
bool isActive() const { return level > -90.0f && targetBusId >= 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Per-channel high-pass filter configuration.
|
||||||
|
struct HpfConfig {
|
||||||
|
bool enabled = false;
|
||||||
|
float frequency = 80.0f; // Hz
|
||||||
|
// Filter state for simple biquad — sized for max buffer
|
||||||
|
// Allocated at prepare time
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A single channel strip in the mixer.
|
||||||
|
///
|
||||||
|
/// Each MixerChannelStrip wraps:
|
||||||
|
/// - A mini-pedalboard (FX chain using existing Lv2Pedalboard)
|
||||||
|
/// - Volume fader, pan pot, mute, solo
|
||||||
|
/// - High-pass filter on input
|
||||||
|
/// - Aux sends (pre/post fader)
|
||||||
|
/// - VU metering
|
||||||
|
///
|
||||||
|
/// The channel operates in the real-time audio thread.
|
||||||
|
/// Control changes are made from the non-realtime thread via atomic snapshots.
|
||||||
|
class MixerChannelStrip {
|
||||||
|
public:
|
||||||
|
MixerChannelStrip(int channelIndex);
|
||||||
|
~MixerChannelStrip();
|
||||||
|
|
||||||
|
// Disable copy
|
||||||
|
MixerChannelStrip(const MixerChannelStrip&) = delete;
|
||||||
|
MixerChannelStrip& operator=(const MixerChannelStrip&) = delete;
|
||||||
|
|
||||||
|
/// Channel identity
|
||||||
|
int channelIndex() const { return channelIndex_; }
|
||||||
|
int64_t instanceId() const { return instanceId_; }
|
||||||
|
|
||||||
|
/// --- Control surface (thread-safe via atomics for simple values) ---
|
||||||
|
|
||||||
|
/// Volume in dB (-inf to +12.0)
|
||||||
|
float volume() const { return volume_; }
|
||||||
|
void setVolume(float db);
|
||||||
|
|
||||||
|
/// Pan: -1.0 (full left) to +1.0 (full right). 0.0 = center.
|
||||||
|
float pan() const { return pan_; }
|
||||||
|
void setPan(float pan);
|
||||||
|
|
||||||
|
/// Mute
|
||||||
|
bool mute() const { return mute_; }
|
||||||
|
void setMute(bool mute);
|
||||||
|
|
||||||
|
/// Solo — overrides mute for monitoring
|
||||||
|
bool solo() const { return solo_; }
|
||||||
|
void setSolo(bool solo);
|
||||||
|
|
||||||
|
/// Channel type for UI classification
|
||||||
|
MixerChannelType channelType() const { return channelType_; }
|
||||||
|
void setChannelType(MixerChannelType type) { channelType_ = type; }
|
||||||
|
|
||||||
|
/// User-assignable label
|
||||||
|
const std::string& label() const { return label_; }
|
||||||
|
void setLabel(const std::string& label) { label_ = label; }
|
||||||
|
|
||||||
|
/// --- Input processing ---
|
||||||
|
|
||||||
|
/// High-pass filter
|
||||||
|
bool hpEnabled() const { return hpEnabled_; }
|
||||||
|
void setHpEnabled(bool enabled) { hpEnabled_ = enabled; }
|
||||||
|
float hpFrequency() const { return hpFrequency_; }
|
||||||
|
void setHpFrequency(float freq) { hpFrequency_ = freq; }
|
||||||
|
|
||||||
|
/// --- FX Chain ---
|
||||||
|
|
||||||
|
/// Access the channel's pedalboard for plugin management
|
||||||
|
Pedalboard& fxChain() { return fxChain_; }
|
||||||
|
const Pedalboard& fxChain() const { return fxChain_; }
|
||||||
|
|
||||||
|
/// Get the real-time processor for this channel's FX chain
|
||||||
|
Lv2Pedalboard* fxProcessor() { return fxProcessor_.get(); }
|
||||||
|
|
||||||
|
/// Prepare the FX chain for processing
|
||||||
|
void prepareFx(IHost* pHost, Lv2PedalboardErrorList& errorList,
|
||||||
|
ExistingEffectMap* existingEffects = nullptr);
|
||||||
|
|
||||||
|
/// --- Aux Sends ---
|
||||||
|
|
||||||
|
void setAuxSend(int index, const AuxSendConfig& config);
|
||||||
|
const AuxSendConfig& auxSend(int index) const;
|
||||||
|
size_t auxSendCount() const { return auxSends_.size(); }
|
||||||
|
void resizeAuxSends(size_t count);
|
||||||
|
|
||||||
|
/// --- Audio Processing (real-time thread) ---
|
||||||
|
|
||||||
|
/// Set sample rate and max buffer size
|
||||||
|
void setSampleRate(uint32_t sampleRate);
|
||||||
|
void setMaxBufferSize(size_t frames);
|
||||||
|
|
||||||
|
/// Process one audio block through this channel strip.
|
||||||
|
/// Reads from input, runs HPF → FX chain, applies volume/pan.
|
||||||
|
/// Output goes to provided output buffer(s).
|
||||||
|
/// Returns the number of processed samples.
|
||||||
|
void process(
|
||||||
|
const float* const* inputBuffers,
|
||||||
|
size_t inputChannels,
|
||||||
|
float* const* outputBuffers,
|
||||||
|
size_t outputChannels,
|
||||||
|
uint32_t frames
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Get post-FX, pre-fader audio for aux send calculation
|
||||||
|
const float* postFxBuffer(int channel) const {
|
||||||
|
if (channel < (int)postFxBuffers_.size()) return postFxBuffers_[channel].data();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get pre-FX audio for pre-fader aux sends
|
||||||
|
const float* preFxBuffer(int channel) const {
|
||||||
|
if (channel < (int)preFxBuffers_.size()) return preFxBuffers_[channel].data();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcuate aux send level considering pre/post fader and mute
|
||||||
|
float effectiveAuxLevel(int auxIndex, bool anySoloActive) const;
|
||||||
|
|
||||||
|
/// VU meter values (peak, not RMS — computed during process)
|
||||||
|
float vuLeft() const { return vuLeft_; }
|
||||||
|
float vuRight() const { return vuRight_; }
|
||||||
|
|
||||||
|
/// --- Lifecycle ---
|
||||||
|
|
||||||
|
void Activate();
|
||||||
|
void Deactivate();
|
||||||
|
void Unprepare();
|
||||||
|
|
||||||
|
private:
|
||||||
|
int channelIndex_;
|
||||||
|
int64_t instanceId_;
|
||||||
|
|
||||||
|
static std::atomic<int64_t> nextInstanceId_;
|
||||||
|
|
||||||
|
// Control values (atomic for RT-safe reads from control thread)
|
||||||
|
std::atomic<float> volume_{-96.0f}; // dB, -inf default
|
||||||
|
std::atomic<float> pan_{0.0f};
|
||||||
|
std::atomic<bool> mute_{false};
|
||||||
|
std::atomic<bool> solo_{false};
|
||||||
|
std::atomic<bool> hpEnabled_{false};
|
||||||
|
std::atomic<float> hpFrequency_{80.0f};
|
||||||
|
|
||||||
|
MixerChannelType channelType_ = MixerChannelType::Instrument;
|
||||||
|
std::string label_;
|
||||||
|
|
||||||
|
// FX chain
|
||||||
|
Pedalboard fxChain_;
|
||||||
|
std::unique_ptr<Lv2Pedalboard> fxProcessor_;
|
||||||
|
|
||||||
|
// Aux sends
|
||||||
|
std::vector<AuxSendConfig> auxSends_;
|
||||||
|
|
||||||
|
// Audio buffers (allocated at prepare time)
|
||||||
|
std::vector<std::vector<float>> preFxBuffers_; // Before FX chain (for pre-fader sends)
|
||||||
|
std::vector<std::vector<float>> postFxBuffers_; // After FX chain, before fader
|
||||||
|
BufferPool bufferPool_;
|
||||||
|
|
||||||
|
// Sample rate / buffer size
|
||||||
|
uint32_t sampleRate_ = 48000;
|
||||||
|
size_t maxBufferSize_ = 512;
|
||||||
|
|
||||||
|
// VU tracking
|
||||||
|
float vuLeft_ = -96.0f;
|
||||||
|
float vuRight_ = -96.0f;
|
||||||
|
|
||||||
|
// Simple 1-pole HPF state (per channel)
|
||||||
|
struct HpfState {
|
||||||
|
float x1 = 0.0f, y1 = 0.0f;
|
||||||
|
};
|
||||||
|
std::vector<HpfState> hpfStates_;
|
||||||
|
|
||||||
|
// Apply pan law: constant power (-3dB center)
|
||||||
|
void applyPan(float& leftGain, float& rightGain) const;
|
||||||
|
|
||||||
|
// Apply HPF biquad to a buffer
|
||||||
|
void applyHpf(float* buffer, uint32_t frames, HpfState& state);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
|
#include "MixerChannelStrip.hpp"
|
||||||
|
#include "MixerBus.hpp"
|
||||||
|
#include "Lv2Pedalboard.hpp"
|
||||||
|
#include "IHost.hpp"
|
||||||
|
#include "MidiEvent.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
std::atomic<int64_t> MixerEngine::nextBusId_{1};
|
||||||
|
|
||||||
|
MixerEngine::MixerEngine()
|
||||||
|
{
|
||||||
|
// Create the master bus by default
|
||||||
|
int64_t masterId = nextBusId_++;
|
||||||
|
auto master = std::make_unique<MixerBus>(masterId, MixerBusType::Master, "Master", 2);
|
||||||
|
masterBus_ = master.get();
|
||||||
|
buses_[masterId] = std::move(master);
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerEngine::~MixerEngine()
|
||||||
|
{
|
||||||
|
Deactivate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::setSampleRate(uint32_t sampleRate)
|
||||||
|
{
|
||||||
|
sampleRate_ = sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::setMaxBufferSize(size_t frames)
|
||||||
|
{
|
||||||
|
maxBufferSize_ = frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Channel Management ---
|
||||||
|
|
||||||
|
MixerChannelStrip* MixerEngine::addChannel(int physicalInputIndex)
|
||||||
|
{
|
||||||
|
auto channel = std::make_unique<MixerChannelStrip>(physicalInputIndex);
|
||||||
|
channel->setSampleRate(sampleRate_);
|
||||||
|
channel->setMaxBufferSize(maxBufferSize_);
|
||||||
|
channel->setLabel("Channel " + std::to_string(physicalInputIndex + 1));
|
||||||
|
|
||||||
|
// Default: route channel directly to master
|
||||||
|
auto* ptr = channel.get();
|
||||||
|
channels_.push_back(std::move(channel));
|
||||||
|
|
||||||
|
// Create default route: this channel → master bus at unity
|
||||||
|
MixerRouteEntry route;
|
||||||
|
route.sourceType = MixerRouteEntry::SourceChannel;
|
||||||
|
route.sourceId = ptr->instanceId();
|
||||||
|
route.targetBusId = masterBus_->id();
|
||||||
|
route.level = 0.0f; // unity
|
||||||
|
routes_.push_back(route);
|
||||||
|
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::removeChannel(int channelIndex)
|
||||||
|
{
|
||||||
|
if (channelIndex < 0 || channelIndex >= (int)channels_.size()) return;
|
||||||
|
|
||||||
|
auto* channel = channels_[channelIndex].get();
|
||||||
|
int64_t instanceId = channel->instanceId();
|
||||||
|
|
||||||
|
// Remove all routes referencing this channel
|
||||||
|
routes_.erase(
|
||||||
|
std::remove_if(routes_.begin(), routes_.end(),
|
||||||
|
[instanceId](const MixerRouteEntry& r) {
|
||||||
|
return r.sourceType == MixerRouteEntry::SourceChannel &&
|
||||||
|
r.sourceId == instanceId;
|
||||||
|
}),
|
||||||
|
routes_.end()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unprepare the channel
|
||||||
|
channel->Unprepare();
|
||||||
|
channels_.erase(channels_.begin() + channelIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerChannelStrip* MixerEngine::getChannel(int channelIndex)
|
||||||
|
{
|
||||||
|
if (channelIndex >= 0 && channelIndex < (int)channels_.size())
|
||||||
|
return channels_[channelIndex].get();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MixerChannelStrip* MixerEngine::getChannel(int channelIndex) const
|
||||||
|
{
|
||||||
|
if (channelIndex >= 0 && channelIndex < (int)channels_.size())
|
||||||
|
return channels_[channelIndex].get();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bus Management ---
|
||||||
|
|
||||||
|
int64_t MixerEngine::addBus(MixerBusType type, const std::string& name, int channels)
|
||||||
|
{
|
||||||
|
int64_t id = nextBusId_++;
|
||||||
|
auto bus = std::make_unique<MixerBus>(id, type, name, channels);
|
||||||
|
bus->allocateBuffers(maxBufferSize_);
|
||||||
|
buses_[id] = std::move(bus);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::removeBus(int64_t busId)
|
||||||
|
{
|
||||||
|
if (busId == masterBus_->id()) return; // Can't remove master
|
||||||
|
|
||||||
|
// Remove all routes targeting this bus
|
||||||
|
routes_.erase(
|
||||||
|
std::remove_if(routes_.begin(), routes_.end(),
|
||||||
|
[busId](const MixerRouteEntry& r) {
|
||||||
|
return r.targetBusId == busId;
|
||||||
|
}),
|
||||||
|
routes_.end()
|
||||||
|
);
|
||||||
|
|
||||||
|
buses_.erase(busId);
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerBus* MixerEngine::getBus(int64_t busId)
|
||||||
|
{
|
||||||
|
auto it = buses_.find(busId);
|
||||||
|
return (it != buses_.end()) ? it->second.get() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MixerBus* MixerEngine::getBus(int64_t busId) const
|
||||||
|
{
|
||||||
|
auto it = buses_.find(busId);
|
||||||
|
return (it != buses_.end()) ? it->second.get() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<int64_t> MixerEngine::busIds() const
|
||||||
|
{
|
||||||
|
std::vector<int64_t> ids;
|
||||||
|
ids.reserve(buses_.size());
|
||||||
|
for (const auto& [id, _] : buses_) {
|
||||||
|
ids.push_back(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Routing ---
|
||||||
|
|
||||||
|
void MixerEngine::routeChannelToBus(int channelIndex, int64_t busId, float levelDb)
|
||||||
|
{
|
||||||
|
if (channelIndex < 0 || channelIndex >= (int)channels_.size()) return;
|
||||||
|
if (!getBus(busId)) return;
|
||||||
|
|
||||||
|
auto* channel = channels_[channelIndex].get();
|
||||||
|
|
||||||
|
// Check if route already exists — update level
|
||||||
|
for (auto& route : routes_) {
|
||||||
|
if (route.sourceType == MixerRouteEntry::SourceChannel &&
|
||||||
|
route.sourceId == channel->instanceId() &&
|
||||||
|
route.targetBusId == busId) {
|
||||||
|
route.level = levelDb;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new route
|
||||||
|
MixerRouteEntry route;
|
||||||
|
route.sourceType = MixerRouteEntry::SourceChannel;
|
||||||
|
route.sourceId = channel->instanceId();
|
||||||
|
route.targetBusId = busId;
|
||||||
|
route.level = levelDb;
|
||||||
|
routes_.push_back(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::routeBusToBus(int64_t sourceBusId, int64_t targetBusId, float levelDb)
|
||||||
|
{
|
||||||
|
if (!getBus(sourceBusId) || !getBus(targetBusId)) return;
|
||||||
|
|
||||||
|
for (auto& route : routes_) {
|
||||||
|
if (route.sourceType == MixerRouteEntry::SourceBus &&
|
||||||
|
route.sourceId == sourceBusId &&
|
||||||
|
route.targetBusId == targetBusId) {
|
||||||
|
route.level = levelDb;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerRouteEntry route;
|
||||||
|
route.sourceType = MixerRouteEntry::SourceBus;
|
||||||
|
route.sourceId = sourceBusId;
|
||||||
|
route.targetBusId = targetBusId;
|
||||||
|
route.level = levelDb;
|
||||||
|
routes_.push_back(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::removeRoute(int64_t sourceId, int64_t targetBusId)
|
||||||
|
{
|
||||||
|
routes_.erase(
|
||||||
|
std::remove_if(routes_.begin(), routes_.end(),
|
||||||
|
[sourceId, targetBusId](const MixerRouteEntry& r) {
|
||||||
|
return r.sourceId == sourceId && r.targetBusId == targetBusId;
|
||||||
|
}),
|
||||||
|
routes_.end()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::clearRoutes()
|
||||||
|
{
|
||||||
|
routes_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
|
||||||
|
void MixerEngine::Prepare(IHost* pHost, Lv2PedalboardErrorList& errorList)
|
||||||
|
{
|
||||||
|
pHost_ = pHost;
|
||||||
|
|
||||||
|
// Allocate bus buffers
|
||||||
|
for (auto& [_, bus] : buses_) {
|
||||||
|
bus->allocateBuffers(maxBufferSize_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate per-channel output buffers (for routing accumulation)
|
||||||
|
channelOutputBuffers_.resize(std::max((size_t)1, channels_.size()));
|
||||||
|
for (auto& buf : channelOutputBuffers_) {
|
||||||
|
buf.resize(maxBufferSize_ * 2, 0.0f); // stereo output per channel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare each channel's FX chain
|
||||||
|
for (auto& channel : channels_) {
|
||||||
|
channel->setSampleRate(sampleRate_);
|
||||||
|
channel->setMaxBufferSize(maxBufferSize_);
|
||||||
|
channel->prepareFx(pHost, errorList, nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::Activate()
|
||||||
|
{
|
||||||
|
for (auto& channel : channels_) {
|
||||||
|
channel->Activate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::Deactivate()
|
||||||
|
{
|
||||||
|
for (auto& channel : channels_) {
|
||||||
|
channel->Deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Solo ---
|
||||||
|
|
||||||
|
bool MixerEngine::anySoloActive() const
|
||||||
|
{
|
||||||
|
for (const auto& channel : channels_) {
|
||||||
|
if (channel->solo()) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audio Processing ---
|
||||||
|
|
||||||
|
std::vector<MixerRouteEntry*> MixerEngine::findRoutesForSource(int64_t sourceId)
|
||||||
|
{
|
||||||
|
std::vector<MixerRouteEntry*> result;
|
||||||
|
for (auto& route : routes_) {
|
||||||
|
if (route.sourceId == sourceId) {
|
||||||
|
result.push_back(&route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::routeChannelOutput(
|
||||||
|
MixerChannelStrip* channel,
|
||||||
|
float** channelOutput,
|
||||||
|
uint32_t frames)
|
||||||
|
{
|
||||||
|
bool soloActive = anySoloActive();
|
||||||
|
|
||||||
|
// Find all routes for this channel
|
||||||
|
int64_t channelId = channel->instanceId();
|
||||||
|
auto channelRoutes = findRoutesForSource(channelId);
|
||||||
|
|
||||||
|
for (auto* route : channelRoutes) {
|
||||||
|
MixerBus* targetBus = getBus(route->targetBusId);
|
||||||
|
if (!targetBus) continue;
|
||||||
|
|
||||||
|
float levelLinear = std::pow(10.0f, route->level / 20.0f);
|
||||||
|
|
||||||
|
// Check aux sends if this is an aux bus
|
||||||
|
// For standard bus routing, just accumulate
|
||||||
|
targetBus->accumulate(
|
||||||
|
(const float* const*)channelOutput,
|
||||||
|
frames,
|
||||||
|
levelLinear,
|
||||||
|
2 // channelOutput is always stereo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process aux sends
|
||||||
|
size_t numAuxSends = channel->auxSendCount();
|
||||||
|
for (size_t auxIdx = 0; auxIdx < numAuxSends; ++auxIdx) {
|
||||||
|
float effectiveLevel = channel->effectiveAuxLevel(auxIdx, soloActive);
|
||||||
|
if (effectiveLevel < -90.0f) continue;
|
||||||
|
|
||||||
|
const auto& sendConfig = channel->auxSend(auxIdx);
|
||||||
|
MixerBus* auxBus = getBus(sendConfig.targetBusId);
|
||||||
|
if (!auxBus) continue;
|
||||||
|
|
||||||
|
float sendGain = std::pow(10.0f, effectiveLevel / 20.0f);
|
||||||
|
|
||||||
|
if (sendConfig.preFader) {
|
||||||
|
// Pre-fader: use the pre-FX buffer
|
||||||
|
// This means we need access to the pre-fader buffer from the channel
|
||||||
|
const float* preFx0 = channel->preFxBuffer(0);
|
||||||
|
const float* preFx1 = channel->preFxBuffer(1);
|
||||||
|
if (preFx0) {
|
||||||
|
const float* preFx[2] = { preFx0, preFx1 };
|
||||||
|
auxBus->accumulate(preFx, frames, sendGain, 2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Post-fader: use the same output that goes to buses
|
||||||
|
auxBus->accumulate(
|
||||||
|
(const float* const*)channelOutput,
|
||||||
|
frames,
|
||||||
|
sendGain,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::processBusRouting(uint32_t frames)
|
||||||
|
{
|
||||||
|
// Process bus-to-bus routes
|
||||||
|
// This is simple: for each bus route, accumulate source bus output to target bus
|
||||||
|
for (auto& route : routes_) {
|
||||||
|
if (route.sourceType != MixerRouteEntry::SourceBus) continue;
|
||||||
|
|
||||||
|
MixerBus* sourceBus = getBus(route.sourceId);
|
||||||
|
MixerBus* targetBus = getBus(route.targetBusId);
|
||||||
|
if (!sourceBus || !targetBus) continue;
|
||||||
|
|
||||||
|
// Build float* array from source bus
|
||||||
|
int nChannels = sourceBus->channelCount();
|
||||||
|
std::vector<const float*> srcPtrs(nChannels);
|
||||||
|
for (int ch = 0; ch < nChannels; ++ch) {
|
||||||
|
srcPtrs[ch] = sourceBus->buffer(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
float levelLinear = std::pow(10.0f, route.level / 20.0f);
|
||||||
|
targetBus->accumulate(srcPtrs.data(), frames, levelLinear, nChannels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::process(
|
||||||
|
float** deviceInputs,
|
||||||
|
uint32_t inputChannels,
|
||||||
|
float** deviceOutputs,
|
||||||
|
uint32_t outputChannels,
|
||||||
|
uint32_t frames)
|
||||||
|
{
|
||||||
|
// Clamp
|
||||||
|
frames = std::min(frames, (uint32_t)maxBufferSize_);
|
||||||
|
|
||||||
|
// Step 1: Clear all bus buffers
|
||||||
|
for (auto& [_, bus] : buses_) {
|
||||||
|
bus->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Process each channel
|
||||||
|
size_t numChannels = channels_.size();
|
||||||
|
|
||||||
|
// Determine channel pairing mode:
|
||||||
|
// For 1-2 total input channels: stereo pairing per strip (backward compatible)
|
||||||
|
// For 3+ input channels: each strip is mono (one input channel → stereo output with pan)
|
||||||
|
bool stereoPairing = (inputChannels <= 2);
|
||||||
|
|
||||||
|
for (size_t ch = 0; ch < numChannels; ++ch) {
|
||||||
|
auto* channel = channels_[ch].get();
|
||||||
|
|
||||||
|
// Build input buffer pointers for this channel
|
||||||
|
float* channelInputs[2] = { nullptr, nullptr };
|
||||||
|
if (stereoPairing) {
|
||||||
|
// Stereo pairing: channel 0 ← inputs[0,1], channel 1 ← inputs[2,3], etc.
|
||||||
|
uint32_t baseInput = (uint32_t)(ch * 2);
|
||||||
|
if (baseInput < inputChannels) {
|
||||||
|
channelInputs[0] = deviceInputs[baseInput];
|
||||||
|
if (baseInput + 1 < inputChannels) {
|
||||||
|
channelInputs[1] = deviceInputs[baseInput + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mono per strip: each channel gets exactly one input
|
||||||
|
if (ch < inputChannels) {
|
||||||
|
channelInputs[0] = deviceInputs[ch];
|
||||||
|
// Second input stays nullptr → mono processing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build output buffer (stereo, from our per-channel scratch buffers)
|
||||||
|
float* channelOutputs[2] = { nullptr, nullptr };
|
||||||
|
if (ch < channelOutputBuffers_.size()) {
|
||||||
|
channelOutputs[0] = channelOutputBuffers_[ch].data();
|
||||||
|
channelOutputs[1] = channelOutputBuffers_[ch].data() + maxBufferSize_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the actual number of input channels for this strip
|
||||||
|
size_t stripInputChannels = 0;
|
||||||
|
if (channelInputs[0] != nullptr) stripInputChannels = 1;
|
||||||
|
if (channelInputs[1] != nullptr) stripInputChannels = 2;
|
||||||
|
|
||||||
|
// Process the channel strip
|
||||||
|
channel->process(
|
||||||
|
(const float* const*)channelInputs,
|
||||||
|
stripInputChannels,
|
||||||
|
channelOutputs,
|
||||||
|
2,
|
||||||
|
frames
|
||||||
|
);
|
||||||
|
|
||||||
|
// Route channel output to buses
|
||||||
|
routeChannelOutput(channel, channelOutputs, frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Process bus-to-bus routing
|
||||||
|
processBusRouting(frames);
|
||||||
|
|
||||||
|
// Step 4: Process each bus (apply volume, compute VU)
|
||||||
|
for (auto& [_, bus] : buses_) {
|
||||||
|
bus->process(frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Write buses to physical outputs according to output routing
|
||||||
|
if (outputRoutes_.empty()) {
|
||||||
|
// Legacy fallback: write master bus to device outputs 1:1
|
||||||
|
if (masterBus_) {
|
||||||
|
for (uint32_t outCh = 0; outCh < outputChannels; ++outCh) {
|
||||||
|
if (deviceOutputs[outCh] == nullptr) continue;
|
||||||
|
|
||||||
|
const float* src = masterBus_->buffer(outCh);
|
||||||
|
if (src) {
|
||||||
|
std::copy(src, src + frames, deviceOutputs[outCh]);
|
||||||
|
} else if (outCh == 1) {
|
||||||
|
const float* srcL = masterBus_->buffer(0);
|
||||||
|
if (srcL) {
|
||||||
|
std::copy(srcL, srcL + frames, deviceOutputs[outCh]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use configured output routes
|
||||||
|
for (const auto& route : outputRoutes_) {
|
||||||
|
MixerBus* sourceBus = getBus(route.sourceBusId);
|
||||||
|
if (!sourceBus) continue;
|
||||||
|
|
||||||
|
for (int ch = 0; ch < route.channels; ++ch) {
|
||||||
|
uint32_t targetCh = (uint32_t)(route.targetStartChannel + ch);
|
||||||
|
if (targetCh >= outputChannels) break;
|
||||||
|
if (deviceOutputs[targetCh] == nullptr) continue;
|
||||||
|
|
||||||
|
const float* src = sourceBus->buffer(route.sourceStartChannel + ch);
|
||||||
|
if (src) {
|
||||||
|
std::copy(src, src + frames, deviceOutputs[targetCh]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MIDI Control Surface Mapping ---
|
||||||
|
|
||||||
|
bool MixerEngine::processMidiEvent(const MidiEvent& event)
|
||||||
|
{
|
||||||
|
midiMapper_.setMixerEngine(this);
|
||||||
|
return midiMapper_.processEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State Serialization ---
|
||||||
|
|
||||||
|
MixerEngine::MixerSnapshot MixerEngine::captureSnapshot() const
|
||||||
|
{
|
||||||
|
MixerSnapshot snap;
|
||||||
|
|
||||||
|
for (const auto& channel : channels_) {
|
||||||
|
MixerSnapshot::ChannelState cs;
|
||||||
|
cs.channelIndex = channel->channelIndex();
|
||||||
|
cs.volume = channel->volume();
|
||||||
|
cs.pan = channel->pan();
|
||||||
|
cs.mute = channel->mute();
|
||||||
|
cs.solo = channel->solo();
|
||||||
|
cs.channelType = channel->channelType();
|
||||||
|
cs.label = channel->label();
|
||||||
|
cs.hpEnabled = channel->hpEnabled();
|
||||||
|
cs.hpFrequency = channel->hpFrequency();
|
||||||
|
cs.vuLeft = channel->vuLeft();
|
||||||
|
cs.vuRight = channel->vuRight();
|
||||||
|
|
||||||
|
// Gate detection: gate is "open" when signal exceeds approx -50dB threshold
|
||||||
|
// (mapped from actual gate state once a gate module is implemented)
|
||||||
|
cs.gateOpen = (cs.vuLeft > -50.0f || cs.vuRight > -50.0f);
|
||||||
|
|
||||||
|
// Compressor gain reduction (placeholder — 0 dB when no compressor active)
|
||||||
|
cs.compressorReduction = 0.0f;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < channel->auxSendCount(); ++i) {
|
||||||
|
cs.auxSendLevels.push_back(channel->auxSend(i).level);
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.channels.push_back(cs);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& [id, bus] : buses_) {
|
||||||
|
MixerSnapshot::BusState bs;
|
||||||
|
bs.id = id;
|
||||||
|
bs.name = bus->name();
|
||||||
|
bs.type = bus->type();
|
||||||
|
bs.volume = bus->volume();
|
||||||
|
bs.mute = bus->mute();
|
||||||
|
bs.vuLeft = bus->vuLeft();
|
||||||
|
bs.vuRight = bus->vuRight();
|
||||||
|
snap.buses.push_back(bs);
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.routes = routes_;
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Output Routing ---
|
||||||
|
|
||||||
|
void MixerEngine::setOutputRoutes(const std::vector<MixerOutputRoute>& routes)
|
||||||
|
{
|
||||||
|
outputRoutes_ = routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::addOutputRoute(int64_t busId, int sourceStartChannel, int targetStartChannel, int channels)
|
||||||
|
{
|
||||||
|
// Remove any existing route that conflicts with the target
|
||||||
|
outputRoutes_.erase(
|
||||||
|
std::remove_if(outputRoutes_.begin(), outputRoutes_.end(),
|
||||||
|
[busId, targetStartChannel](const MixerOutputRoute& r) {
|
||||||
|
return r.sourceBusId == busId &&
|
||||||
|
r.targetStartChannel == targetStartChannel;
|
||||||
|
}),
|
||||||
|
outputRoutes_.end()
|
||||||
|
);
|
||||||
|
|
||||||
|
MixerOutputRoute route;
|
||||||
|
route.sourceBusId = busId;
|
||||||
|
route.sourceStartChannel = sourceStartChannel;
|
||||||
|
route.targetStartChannel = targetStartChannel;
|
||||||
|
route.channels = channels;
|
||||||
|
outputRoutes_.push_back(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::removeOutputRoutes(int64_t busId)
|
||||||
|
{
|
||||||
|
outputRoutes_.erase(
|
||||||
|
std::remove_if(outputRoutes_.begin(), outputRoutes_.end(),
|
||||||
|
[busId](const MixerOutputRoute& r) { return r.sourceBusId == busId; }),
|
||||||
|
outputRoutes_.end()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<MixerEngine::MixerOutputRoute> MixerEngine::findOutputRoutesForBus(int64_t busId) const
|
||||||
|
{
|
||||||
|
std::vector<MixerOutputRoute> result;
|
||||||
|
for (const auto& route : outputRoutes_) {
|
||||||
|
if (route.sourceBusId == busId) {
|
||||||
|
result.push_back(route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auto channel creation ---
|
||||||
|
|
||||||
|
void MixerEngine::autoCreateChannels(uint32_t inputChannelCount, uint32_t outputChannelCount)
|
||||||
|
{
|
||||||
|
// Store the physical I/O channel counts for later queries
|
||||||
|
physicalInputCount_ = inputChannelCount;
|
||||||
|
physicalOutputCount_ = outputChannelCount;
|
||||||
|
|
||||||
|
// Remove existing channels if any
|
||||||
|
for (int i = (int)channels_.size() - 1; i >= 0; --i) {
|
||||||
|
removeChannel(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create one channel strip per input
|
||||||
|
for (uint32_t i = 0; i < inputChannelCount; ++i) {
|
||||||
|
addChannel((int)i);
|
||||||
|
auto* ch = getChannel((int)i);
|
||||||
|
if (ch) {
|
||||||
|
char label[32];
|
||||||
|
snprintf(label, sizeof(label), "Input %u", i + 1);
|
||||||
|
ch->setLabel(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default output routes:
|
||||||
|
// Always route master bus to physical 1-2 (the main monitor output)
|
||||||
|
// For multi-channel setups, individual channel routing is configured via the output routing UI.
|
||||||
|
outputRoutes_.clear();
|
||||||
|
|
||||||
|
if (!masterBus_) return;
|
||||||
|
|
||||||
|
MixerOutputRoute masterRoute;
|
||||||
|
masterRoute.sourceBusId = masterBus_->id();
|
||||||
|
masterRoute.sourceStartChannel = 0;
|
||||||
|
masterRoute.targetStartChannel = 0;
|
||||||
|
masterRoute.channels = 2;
|
||||||
|
outputRoutes_.push_back(masterRoute);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::applySnapshot(const MixerSnapshot& snapshot)
|
||||||
|
{
|
||||||
|
// Apply channel states
|
||||||
|
for (const auto& cs : snapshot.channels) {
|
||||||
|
auto* channel = getChannel(cs.channelIndex);
|
||||||
|
if (!channel) continue;
|
||||||
|
|
||||||
|
channel->setVolume(cs.volume);
|
||||||
|
channel->setPan(cs.pan);
|
||||||
|
channel->setMute(cs.mute);
|
||||||
|
channel->setSolo(cs.solo);
|
||||||
|
channel->setChannelType(cs.channelType);
|
||||||
|
channel->setLabel(cs.label);
|
||||||
|
channel->setHpEnabled(cs.hpEnabled);
|
||||||
|
channel->setHpFrequency(cs.hpFrequency);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < cs.auxSendLevels.size() && i < channel->auxSendCount(); ++i) {
|
||||||
|
auto config = channel->auxSend(i);
|
||||||
|
config.level = cs.auxSendLevels[i];
|
||||||
|
channel->setAuxSend(i, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply bus states
|
||||||
|
for (const auto& bs : snapshot.buses) {
|
||||||
|
auto* bus = getBus(bs.id);
|
||||||
|
if (!bus) continue;
|
||||||
|
|
||||||
|
bus->setName(bs.name);
|
||||||
|
bus->setVolume(bs.volume);
|
||||||
|
bus->setMute(bs.mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace routes
|
||||||
|
routes_ = snapshot.routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
#include <map>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
#include "MixerChannelStrip.hpp"
|
||||||
|
#include "MixerBus.hpp"
|
||||||
|
#include "MidiMapper.hpp"
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
class MixerChannelStrip;
|
||||||
|
class MixerBus;
|
||||||
|
class Lv2PedalboardErrorList;
|
||||||
|
class IHost;
|
||||||
|
|
||||||
|
/// Routing entry: a source (channel or bus) feeds a target bus with a level.
|
||||||
|
struct MixerRouteEntry {
|
||||||
|
enum SourceType {
|
||||||
|
SourceChannel,
|
||||||
|
SourceBus
|
||||||
|
};
|
||||||
|
|
||||||
|
SourceType sourceType;
|
||||||
|
int64_t sourceId; // channel instanceId or bus ID
|
||||||
|
int64_t targetBusId; // the bus being fed into
|
||||||
|
float level = 0.0f; // dB
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The MixerEngine is the heart of the band-in-a-box digital mixer.
|
||||||
|
///
|
||||||
|
/// It owns and manages:
|
||||||
|
/// - N channel strips (MixerChannelStrip), one per physical/logical input
|
||||||
|
/// - M buses (MixerBus), including master, subgroups, aux sends
|
||||||
|
/// - A routing graph connecting channels to buses and buses to buses
|
||||||
|
///
|
||||||
|
/// Processing order per audio cycle:
|
||||||
|
/// 1. Clear all bus buffers
|
||||||
|
/// 2. For each channel: process FX chain → apply volume/pan → accumulate to routed buses
|
||||||
|
/// 3. For each aux send: calculate send level, accumulate to aux buses
|
||||||
|
/// 4. Route buses to buses according to routing matrix
|
||||||
|
/// 5. Process each bus (apply volume, compute VU)
|
||||||
|
/// 6. Master bus outputs are the final mix
|
||||||
|
///
|
||||||
|
/// All control methods are thread-safe for use from the non-RT thread.
|
||||||
|
/// The process() method runs in the RT audio thread.
|
||||||
|
class MixerEngine {
|
||||||
|
public:
|
||||||
|
MixerEngine();
|
||||||
|
~MixerEngine();
|
||||||
|
|
||||||
|
// Disable copy
|
||||||
|
MixerEngine(const MixerEngine&) = delete;
|
||||||
|
MixerEngine& operator=(const MixerEngine&) = delete;
|
||||||
|
|
||||||
|
/// --- Configuration ---
|
||||||
|
|
||||||
|
/// Set sample rate and max buffer size before preparation
|
||||||
|
void setSampleRate(uint32_t sampleRate);
|
||||||
|
void setMaxBufferSize(size_t frames);
|
||||||
|
|
||||||
|
/// --- Channel Management ---
|
||||||
|
|
||||||
|
/// Add a new channel strip for the given physical input index.
|
||||||
|
/// Returns a pointer to the new channel (valid until removed).
|
||||||
|
MixerChannelStrip* addChannel(int physicalInputIndex);
|
||||||
|
|
||||||
|
/// Auto-create channels based on the number of detected input channels.
|
||||||
|
/// Removes existing channels and creates one strip per input.
|
||||||
|
/// Also sets up default output routes.
|
||||||
|
/// @param inputChannelCount Number of physical input channels detected
|
||||||
|
/// @param outputChannelCount Number of physical output channels detected
|
||||||
|
void autoCreateChannels(uint32_t inputChannelCount, uint32_t outputChannelCount = 2);
|
||||||
|
|
||||||
|
/// Remove a channel by its channel index.
|
||||||
|
void removeChannel(int channelIndex);
|
||||||
|
|
||||||
|
/// Get a channel by index. Returns nullptr if not found.
|
||||||
|
MixerChannelStrip* getChannel(int channelIndex);
|
||||||
|
const MixerChannelStrip* getChannel(int channelIndex) const;
|
||||||
|
|
||||||
|
/// Number of channels currently in the mixer.
|
||||||
|
size_t channelCount() const { return channels_.size(); }
|
||||||
|
|
||||||
|
/// --- Bus Management ---
|
||||||
|
|
||||||
|
/// Add a new bus and return its ID.
|
||||||
|
int64_t addBus(MixerBusType type, const std::string& name, int channels = 2);
|
||||||
|
|
||||||
|
/// Remove a bus by ID.
|
||||||
|
void removeBus(int64_t busId);
|
||||||
|
|
||||||
|
/// Get a bus by ID. Returns nullptr if not found.
|
||||||
|
MixerBus* getBus(int64_t busId);
|
||||||
|
const MixerBus* getBus(int64_t busId) const;
|
||||||
|
|
||||||
|
/// Access the master bus (always present).
|
||||||
|
MixerBus* masterBus() { return masterBus_; }
|
||||||
|
const MixerBus* masterBus() const { return masterBus_; }
|
||||||
|
|
||||||
|
/// Get all bus IDs (for iteration).
|
||||||
|
std::vector<int64_t> busIds() const;
|
||||||
|
|
||||||
|
/// --- Routing ---
|
||||||
|
|
||||||
|
/// Route a channel to a bus with a given level in dB.
|
||||||
|
void routeChannelToBus(int channelIndex, int64_t busId, float levelDb = 0.0f);
|
||||||
|
|
||||||
|
/// Route a bus to another bus (e.g., subgroup to master).
|
||||||
|
void routeBusToBus(int64_t sourceBusId, int64_t targetBusId, float levelDb = 0.0f);
|
||||||
|
|
||||||
|
/// Remove a route.
|
||||||
|
void removeRoute(int64_t sourceId, int64_t targetBusId);
|
||||||
|
|
||||||
|
/// Clear all routes.
|
||||||
|
void clearRoutes();
|
||||||
|
|
||||||
|
/// --- Output Routing ---
|
||||||
|
|
||||||
|
/// Describes a mapping from a mixer bus to physical output channels.
|
||||||
|
/// Multiple routes can be active simultaneously (e.g. Master→1-2, Aux1→3-4).
|
||||||
|
struct MixerOutputRoute {
|
||||||
|
int64_t sourceBusId; // Bus to route from
|
||||||
|
int sourceStartChannel; // Starting channel on the bus (0=L, 1=R)
|
||||||
|
int targetStartChannel; // Starting physical output channel
|
||||||
|
int channels; // Number of consecutive channels to route (1 or 2 typically)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Get the current output routing table (bus → physical output channel mapping).
|
||||||
|
const std::vector<MixerOutputRoute>& outputRoutes() const { return outputRoutes_; }
|
||||||
|
|
||||||
|
/// Set the entire output routing table.
|
||||||
|
void setOutputRoutes(const std::vector<MixerOutputRoute>& routes);
|
||||||
|
|
||||||
|
/// Add a single output route.
|
||||||
|
void addOutputRoute(int64_t busId, int sourceStartChannel, int targetStartChannel, int channels);
|
||||||
|
|
||||||
|
/// Remove all output routes for a given bus.
|
||||||
|
void removeOutputRoutes(int64_t busId);
|
||||||
|
|
||||||
|
/// Get all routes for a given bus.
|
||||||
|
std::vector<MixerOutputRoute> findOutputRoutesForBus(int64_t busId) const;
|
||||||
|
|
||||||
|
/// Get all current routes.
|
||||||
|
const std::vector<MixerRouteEntry>& routes() const { return routes_; }
|
||||||
|
|
||||||
|
/// --- Physical I/O Info ---
|
||||||
|
|
||||||
|
/// Number of physical input channels detected on the audio device.
|
||||||
|
uint32_t physicalInputCount() const { return physicalInputCount_; }
|
||||||
|
/// Number of physical output channels detected on the audio device.
|
||||||
|
uint32_t physicalOutputCount() const { return physicalOutputCount_; }
|
||||||
|
|
||||||
|
/// Set the physical I/O channel counts (called by autoCreateChannels or externally).
|
||||||
|
void setPhysicalChannelCounts(uint32_t inputs, uint32_t outputs) {
|
||||||
|
physicalInputCount_ = inputs;
|
||||||
|
physicalOutputCount_ = outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// --- Audio Processing (real-time thread) ---
|
||||||
|
|
||||||
|
/// Prepare all channels and allocate buffers.
|
||||||
|
void Prepare(IHost* pHost, Lv2PedalboardErrorList& errorList);
|
||||||
|
|
||||||
|
/// Activate all channels.
|
||||||
|
void Activate();
|
||||||
|
|
||||||
|
/// Deactivate all channels.
|
||||||
|
void Deactivate();
|
||||||
|
|
||||||
|
/// Process one full mixer cycle.
|
||||||
|
/// deviceInputs/outputs are the raw audio interface buffers.
|
||||||
|
/// The mixer reads from inputs, processes through channels → buses, writes to outputs.
|
||||||
|
void process(
|
||||||
|
float** deviceInputs,
|
||||||
|
uint32_t inputChannels,
|
||||||
|
float** deviceOutputs,
|
||||||
|
uint32_t outputChannels,
|
||||||
|
uint32_t frames
|
||||||
|
);
|
||||||
|
|
||||||
|
/// --- Solo Management ---
|
||||||
|
|
||||||
|
/// True if any channel has solo engaged.
|
||||||
|
bool anySoloActive() const;
|
||||||
|
|
||||||
|
/// --- MIDI Control Surface Mapping ---
|
||||||
|
|
||||||
|
/// Access the MIDI mapper for CC control surface mapping.
|
||||||
|
MidiMapper& midiMapper() { return midiMapper_; }
|
||||||
|
const MidiMapper& midiMapper() const { return midiMapper_; }
|
||||||
|
|
||||||
|
/// Process a MIDI event (typically from the real-time audio thread).
|
||||||
|
/// Routes CC messages to the midi mapper. Returns true if consumed.
|
||||||
|
bool processMidiEvent(const struct MidiEvent& event);
|
||||||
|
|
||||||
|
/// --- State Serialization ---
|
||||||
|
|
||||||
|
struct MixerSnapshot {
|
||||||
|
struct ChannelState {
|
||||||
|
int channelIndex;
|
||||||
|
float volume;
|
||||||
|
float pan;
|
||||||
|
bool mute;
|
||||||
|
bool solo;
|
||||||
|
MixerChannelType channelType;
|
||||||
|
std::string label;
|
||||||
|
bool hpEnabled;
|
||||||
|
float hpFrequency;
|
||||||
|
std::vector<float> auxSendLevels; // indexed by aux bus index
|
||||||
|
float vuLeft = -96.0f; // dB — peak VU level
|
||||||
|
float vuRight = -96.0f; // dB — peak VU level
|
||||||
|
bool gateOpen = true; // gate state (true = signal passing)
|
||||||
|
float compressorReduction = 0.0f; // dB of gain reduction (0 = no reduction)
|
||||||
|
};
|
||||||
|
struct BusState {
|
||||||
|
int64_t id;
|
||||||
|
std::string name;
|
||||||
|
MixerBusType type;
|
||||||
|
float volume;
|
||||||
|
bool mute;
|
||||||
|
float vuLeft = -96.0f; // dB — peak VU level
|
||||||
|
float vuRight = -96.0f; // dB — peak VU level
|
||||||
|
};
|
||||||
|
std::vector<ChannelState> channels;
|
||||||
|
std::vector<BusState> buses;
|
||||||
|
std::vector<MixerRouteEntry> routes;
|
||||||
|
};
|
||||||
|
|
||||||
|
MixerSnapshot captureSnapshot() const;
|
||||||
|
void applySnapshot(const MixerSnapshot& snapshot);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<std::unique_ptr<MixerChannelStrip>> channels_;
|
||||||
|
std::map<int64_t, std::unique_ptr<MixerBus>> buses_;
|
||||||
|
MixerBus* masterBus_ = nullptr;
|
||||||
|
|
||||||
|
// Routing entries
|
||||||
|
std::vector<MixerRouteEntry> routes_;
|
||||||
|
|
||||||
|
// Output routing entries (bus → physical output channel mapping)
|
||||||
|
std::vector<MixerOutputRoute> outputRoutes_;
|
||||||
|
|
||||||
|
// Audio configuration
|
||||||
|
uint32_t sampleRate_ = 48000;
|
||||||
|
size_t maxBufferSize_ = 512;
|
||||||
|
|
||||||
|
// IHost reference for FX preparation
|
||||||
|
IHost* pHost_ = nullptr;
|
||||||
|
|
||||||
|
// MIDI CC control surface mapper
|
||||||
|
MidiMapper midiMapper_;
|
||||||
|
|
||||||
|
// Next bus ID counter
|
||||||
|
static std::atomic<int64_t> nextBusId_;
|
||||||
|
|
||||||
|
// Temporary per-channel output buffers for routing
|
||||||
|
// Allocated once at prepare time
|
||||||
|
std::vector<std::vector<float>> channelOutputBuffers_;
|
||||||
|
|
||||||
|
// Physical I/O channel counts (from audio device auto-detection)
|
||||||
|
uint32_t physicalInputCount_ = 0;
|
||||||
|
uint32_t physicalOutputCount_ = 0;
|
||||||
|
|
||||||
|
// Internal helper: accumulate a channel's output to all its routed buses
|
||||||
|
void routeChannelOutput(
|
||||||
|
MixerChannelStrip* channel,
|
||||||
|
float** channelOutput,
|
||||||
|
uint32_t frames
|
||||||
|
);
|
||||||
|
|
||||||
|
// Internal helper: process all bus-to-bus routing
|
||||||
|
void processBusRouting(uint32_t frames);
|
||||||
|
|
||||||
|
// Build a list of all routes from a given source
|
||||||
|
std::vector<MixerRouteEntry*> findRoutesForSource(int64_t sourceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
+29
-1
@@ -33,6 +33,7 @@
|
|||||||
#include "CpuGovernor.hpp"
|
#include "CpuGovernor.hpp"
|
||||||
#include "RegDb.hpp"
|
#include "RegDb.hpp"
|
||||||
#include "RingBufferReader.hpp"
|
#include "RingBufferReader.hpp"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
#include "PiPedalUI.hpp"
|
#include "PiPedalUI.hpp"
|
||||||
#include "atom_object.hpp"
|
#include "atom_object.hpp"
|
||||||
#include "Lv2PluginChangeMonitor.hpp"
|
#include "Lv2PluginChangeMonitor.hpp"
|
||||||
@@ -438,7 +439,7 @@ void PiPedalModel::Load()
|
|||||||
|
|
||||||
UpdateDefaults(&this->pedalboard);
|
UpdateDefaults(&this->pedalboard);
|
||||||
|
|
||||||
std::unique_ptr<AudioHost> p{AudioHost::CreateInstance(pluginHost.asIHost())};
|
std::unique_ptr<AudioHost> p{AudioHost::CreateInstance(pluginHost.asIHost(), this->driverType_)};
|
||||||
this->audioHost = std::move(p);
|
this->audioHost = std::move(p);
|
||||||
|
|
||||||
this->audioHost->SetNotificationCallbacks(this);
|
this->audioHost->SetNotificationCallbacks(this);
|
||||||
@@ -3081,6 +3082,33 @@ std::shared_ptr<Lv2Pedalboard> PiPedalModel::GetLv2Pedalboard()
|
|||||||
return lv2Pedalboard;
|
return lv2Pedalboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PiPedalModel::SetMixerEngine(const std::shared_ptr<MixerEngine>& engine)
|
||||||
|
{
|
||||||
|
this->mixerEngine = engine;
|
||||||
|
if (this->audioHost)
|
||||||
|
{
|
||||||
|
this->audioHost->SetMixerEngine(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (engine && this->audioHost && this->audioHost->IsOpen())
|
||||||
|
{
|
||||||
|
// Auto-create channels based on detected input channel count
|
||||||
|
uint32_t inputChannels = this->audioHost->GetDeviceCaptureChannels();
|
||||||
|
if (inputChannels == 0) inputChannels = 2; // default to stereo
|
||||||
|
|
||||||
|
engine->autoCreateChannels(inputChannels);
|
||||||
|
|
||||||
|
Lv2Log::info(SS("Mixer engine initialized with "
|
||||||
|
<< inputChannels << " channels, "
|
||||||
|
<< engine->channelCount() << " strips created."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved MIDI control surface mappings
|
||||||
|
if (engine) {
|
||||||
|
engine->midiMapper().loadFromFile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void PiPedalModel::SetSelectedPedalboardPlugin(uint64_t clientId, uint64_t pedalboardId)
|
void PiPedalModel::SetSelectedPedalboardPlugin(uint64_t clientId, uint64_t pedalboardId)
|
||||||
{
|
{
|
||||||
// Thinking on this:
|
// Thinking on this:
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ namespace pipedal
|
|||||||
std::unique_ptr<AudioHost> audioHost;
|
std::unique_ptr<AudioHost> audioHost;
|
||||||
JackConfiguration jackConfiguration;
|
JackConfiguration jackConfiguration;
|
||||||
std::shared_ptr<Lv2Pedalboard> lv2Pedalboard;
|
std::shared_ptr<Lv2Pedalboard> lv2Pedalboard;
|
||||||
|
std::shared_ptr<MixerEngine> mixerEngine;
|
||||||
std::filesystem::path webRoot;
|
std::filesystem::path webRoot;
|
||||||
|
|
||||||
using SubscriberList = std::vector<std::shared_ptr<IPiPedalModelSubscriber>>;
|
using SubscriberList = std::vector<std::shared_ptr<IPiPedalModelSubscriber>>;
|
||||||
@@ -285,6 +286,7 @@ namespace pipedal
|
|||||||
void UpdateVst3Settings(Pedalboard &pedalboard);
|
void UpdateVst3Settings(Pedalboard &pedalboard);
|
||||||
|
|
||||||
PiPedalConfiguration configuration;
|
PiPedalConfiguration configuration;
|
||||||
|
std::string driverType_ = "alsa";
|
||||||
|
|
||||||
void CheckForResourceInitialization(Pedalboard &pedalboard);
|
void CheckForResourceInitialization(Pedalboard &pedalboard);
|
||||||
UiFileProperty::ptr FindLoadedPatchProperty(int64_t instanceId, const std::string &patchPropertyUri);
|
UiFileProperty::ptr FindLoadedPatchProperty(int64_t instanceId, const std::string &patchPropertyUri);
|
||||||
@@ -293,6 +295,9 @@ namespace pipedal
|
|||||||
PiPedalModel();
|
PiPedalModel();
|
||||||
virtual ~PiPedalModel();
|
virtual ~PiPedalModel();
|
||||||
|
|
||||||
|
void SetDriverType(const std::string &driverType) { driverType_ = driverType; }
|
||||||
|
const std::string &GetDriverType() const { return driverType_; }
|
||||||
|
|
||||||
enum class Direction
|
enum class Direction
|
||||||
{
|
{
|
||||||
Increase,
|
Increase,
|
||||||
@@ -401,6 +406,8 @@ namespace pipedal
|
|||||||
void SetPedalboard(int64_t clientId, Pedalboard &pedalboard);
|
void SetPedalboard(int64_t clientId, Pedalboard &pedalboard);
|
||||||
Pedalboard &GetPedalboard();
|
Pedalboard &GetPedalboard();
|
||||||
std::shared_ptr<Lv2Pedalboard> GetLv2Pedalboard();
|
std::shared_ptr<Lv2Pedalboard> GetLv2Pedalboard();
|
||||||
|
std::shared_ptr<MixerEngine> GetMixerEngine() { return mixerEngine; }
|
||||||
|
void SetMixerEngine(const std::shared_ptr<MixerEngine>& engine);
|
||||||
|
|
||||||
void UpdateCurrentPedalboard(int64_t clientId, Pedalboard &pedalboard);
|
void UpdateCurrentPedalboard(int64_t clientId, Pedalboard &pedalboard);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
#include "Curl.hpp"
|
#include "Curl.hpp"
|
||||||
#include "PiPedalSocket.hpp"
|
#include "PiPedalSocket.hpp"
|
||||||
|
#include "MixerApi.hpp"
|
||||||
#include "Updater.hpp"
|
#include "Updater.hpp"
|
||||||
#include "json.hpp"
|
#include "json.hpp"
|
||||||
#include "viewstream.hpp"
|
#include "viewstream.hpp"
|
||||||
@@ -707,6 +708,7 @@ private:
|
|||||||
|
|
||||||
std::recursive_mutex writeMutex;
|
std::recursive_mutex writeMutex;
|
||||||
PiPedalModel &model;
|
PiPedalModel &model;
|
||||||
|
MixerApi mixerApi;
|
||||||
static std::atomic<uint64_t> nextClientId;
|
static std::atomic<uint64_t> nextClientId;
|
||||||
std::string imageList;
|
std::string imageList;
|
||||||
|
|
||||||
@@ -814,6 +816,12 @@ public:
|
|||||||
PiPedalSocketHandler(PiPedalModel &model)
|
PiPedalSocketHandler(PiPedalModel &model)
|
||||||
: model(model), clientId(++nextClientId)
|
: model(model), clientId(++nextClientId)
|
||||||
{
|
{
|
||||||
|
// Wire MixerEngine to MixerApi if available
|
||||||
|
auto engine = model.GetMixerEngine();
|
||||||
|
if (engine) {
|
||||||
|
this->mixerApi.setMixerEngine(engine.get());
|
||||||
|
}
|
||||||
|
|
||||||
std::stringstream imageList;
|
std::stringstream imageList;
|
||||||
const std::filesystem::path &webRoot = model.GetWebRoot() / "img";
|
const std::filesystem::path &webRoot = model.GetWebRoot() / "img";
|
||||||
bool firstTime = true;
|
bool firstTime = true;
|
||||||
@@ -1217,6 +1225,289 @@ public:
|
|||||||
}
|
}
|
||||||
REGISTER_MESSAGE_HANDLER(writeTone3000Readme)
|
REGISTER_MESSAGE_HANDLER(writeTone3000Readme)
|
||||||
|
|
||||||
|
/************************************************************************/
|
||||||
|
/* Band-in-a-Box Mixer Messages */
|
||||||
|
/************************************************************************/
|
||||||
|
|
||||||
|
void handle_mixerSetChannelVolume(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
double volume;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&volume);
|
||||||
|
this->mixerApi.setChannelVolume(channelIndex, (float)volume);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelVolume)
|
||||||
|
|
||||||
|
void handle_mixerSetChannelPan(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
double pan;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&pan);
|
||||||
|
this->mixerApi.setChannelPan(channelIndex, (float)pan);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelPan)
|
||||||
|
|
||||||
|
void handle_mixerSetChannelMute(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
bool mute;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&mute);
|
||||||
|
this->mixerApi.setChannelMute(channelIndex, mute);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelMute)
|
||||||
|
|
||||||
|
void handle_mixerSetChannelSolo(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
bool solo;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&solo);
|
||||||
|
this->mixerApi.setChannelSolo(channelIndex, solo);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelSolo)
|
||||||
|
|
||||||
|
void handle_mixerSetChannelLabel(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
std::string label;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&label);
|
||||||
|
this->mixerApi.setChannelLabel(channelIndex, label);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelLabel)
|
||||||
|
|
||||||
|
void handle_mixerSetChannelHpf(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
bool enabled;
|
||||||
|
double frequency;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&enabled);
|
||||||
|
pReader->read(&frequency);
|
||||||
|
this->mixerApi.setChannelHpf(channelIndex, enabled, (float)frequency);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelHpf)
|
||||||
|
|
||||||
|
void handle_mixerGetState(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string stateJson = this->mixerApi.getStateJson();
|
||||||
|
this->JsonReply(replyTo, "mixerState", stateJson.c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerGetState)
|
||||||
|
|
||||||
|
void handle_mixerAddChannel(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int physicalInputIndex;
|
||||||
|
pReader->read(&physicalInputIndex);
|
||||||
|
int channelIndex = this->mixerApi.addChannel(physicalInputIndex);
|
||||||
|
this->Reply(replyTo, "mixerAddChannel", (int64_t)channelIndex);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerAddChannel)
|
||||||
|
|
||||||
|
void handle_mixerRemoveChannel(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
this->mixerApi.removeChannel(channelIndex);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerRemoveChannel)
|
||||||
|
|
||||||
|
void handle_mixerSetBusVolume(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int64_t busId;
|
||||||
|
double volume;
|
||||||
|
pReader->read(&busId);
|
||||||
|
pReader->read(&volume);
|
||||||
|
this->mixerApi.setBusVolume(busId, (float)volume);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetBusVolume)
|
||||||
|
|
||||||
|
void handle_mixerSetBusMute(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int64_t busId;
|
||||||
|
bool mute;
|
||||||
|
pReader->read(&busId);
|
||||||
|
pReader->read(&mute);
|
||||||
|
this->mixerApi.setBusMute(busId, mute);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetBusMute)
|
||||||
|
|
||||||
|
void handle_mixerRouteChannelToBus(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
int64_t busId;
|
||||||
|
double level;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&busId);
|
||||||
|
pReader->read(&level);
|
||||||
|
this->mixerApi.routeChannelToBus(channelIndex, busId, (float)level);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerRouteChannelToBus)
|
||||||
|
|
||||||
|
void handle_mixerAddBus(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string type;
|
||||||
|
std::string name;
|
||||||
|
int channels;
|
||||||
|
pReader->read(&type);
|
||||||
|
pReader->read(&name);
|
||||||
|
pReader->read(&channels);
|
||||||
|
int64_t busId = this->mixerApi.addBus(type, name, channels);
|
||||||
|
this->Reply(replyTo, "mixerAddBus", busId);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerAddBus)
|
||||||
|
|
||||||
|
void handle_mixerRemoveBus(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int64_t busId;
|
||||||
|
pReader->read(&busId);
|
||||||
|
this->mixerApi.removeBus(busId);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerRemoveBus)
|
||||||
|
|
||||||
|
void handle_mixerSaveScene(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int64_t sceneId;
|
||||||
|
std::string name;
|
||||||
|
pReader->read(&sceneId);
|
||||||
|
pReader->read(&name);
|
||||||
|
std::string result = this->mixerApi.saveScene(name);
|
||||||
|
this->JsonReply(replyTo, "mixerSaveScene", result.c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSaveScene)
|
||||||
|
|
||||||
|
void handle_mixerLoadScene(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int64_t sceneId;
|
||||||
|
pReader->read(&sceneId);
|
||||||
|
bool ok = this->mixerApi.loadScene(std::to_string(sceneId));
|
||||||
|
this->Reply(replyTo, "mixerLoadScene", ok);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerLoadScene)
|
||||||
|
|
||||||
|
void handle_mixerGetScenes(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string result = this->mixerApi.listScenes();
|
||||||
|
this->JsonReply(replyTo, "mixerGetScenes", result.c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerGetScenes)
|
||||||
|
|
||||||
|
/************************************************************************/
|
||||||
|
/* Mixer Output Routing Messages */
|
||||||
|
/************************************************************************/
|
||||||
|
|
||||||
|
void handle_mixerGetOutputRoutes(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string json = this->mixerApi.getOutputRoutesJson();
|
||||||
|
this->JsonReply(replyTo, "mixerOutputRoutes", json.c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerGetOutputRoutes)
|
||||||
|
|
||||||
|
void handle_mixerSetOutputRoutes(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string json;
|
||||||
|
pReader->read(&json);
|
||||||
|
this->mixerApi.setOutputRoutesFromJson(json);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetOutputRoutes)
|
||||||
|
|
||||||
|
/************************************************************************/
|
||||||
|
/* MIDI Control Surface Mapping Messages */
|
||||||
|
/************************************************************************/
|
||||||
|
|
||||||
|
void handle_mixerGetMidiMappings(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string json = this->mixerApi.getMidiMappingsJson();
|
||||||
|
this->JsonReply(replyTo, "mixerMidiMappings", json.c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerGetMidiMappings)
|
||||||
|
|
||||||
|
void handle_mixerSetMidiMappings(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string json;
|
||||||
|
pReader->read(&json);
|
||||||
|
this->mixerApi.setMidiMappingsFromJson(json);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetMidiMappings)
|
||||||
|
|
||||||
|
void handle_mixerAddMidiMapping(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int midiChannel;
|
||||||
|
int ccNumber;
|
||||||
|
std::string targetType;
|
||||||
|
int64_t targetId;
|
||||||
|
double minValue = 0.0;
|
||||||
|
double maxValue = 1.0;
|
||||||
|
pReader->read(&midiChannel);
|
||||||
|
pReader->read(&ccNumber);
|
||||||
|
pReader->read(&targetType);
|
||||||
|
pReader->read(&targetId);
|
||||||
|
pReader->read(&minValue);
|
||||||
|
pReader->read(&maxValue);
|
||||||
|
this->mixerApi.addMidiMapping(
|
||||||
|
midiChannel, ccNumber, targetType, targetId,
|
||||||
|
(float)minValue, (float)maxValue);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerAddMidiMapping)
|
||||||
|
|
||||||
|
void handle_mixerRemoveMidiMapping(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int midiChannel;
|
||||||
|
int ccNumber;
|
||||||
|
pReader->read(&midiChannel);
|
||||||
|
pReader->read(&ccNumber);
|
||||||
|
this->mixerApi.removeMidiMapping(midiChannel, ccNumber);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerRemoveMidiMapping)
|
||||||
|
|
||||||
|
void handle_mixerClearMidiMappings(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
this->mixerApi.clearMidiMappings();
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerClearMidiMappings)
|
||||||
|
|
||||||
|
void handle_mixerSetMidiLearnMode(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
bool enabled;
|
||||||
|
pReader->read(&enabled);
|
||||||
|
this->mixerApi.setMidiLearnMode(enabled);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetMidiLearnMode)
|
||||||
|
|
||||||
|
void handle_mixerSetMidiLearnTarget(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string targetType;
|
||||||
|
int64_t targetId;
|
||||||
|
pReader->read(&targetType);
|
||||||
|
pReader->read(&targetId);
|
||||||
|
this->mixerApi.setMidiLearnTarget(targetType, targetId);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetMidiLearnTarget)
|
||||||
|
|
||||||
|
void handle_mixerCommitMidiLearn(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
bool success = this->mixerApi.commitMidiLearnMapping();
|
||||||
|
this->Reply(replyTo, "mixerCommitMidiLearn", success);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerCommitMidiLearn)
|
||||||
|
|
||||||
|
void handle_mixerGetLastLearnedMidiEvent(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
auto info = this->mixerApi.getLastLearnedMidiEvent();
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss);
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("hasEvent", info.hasEvent);
|
||||||
|
writer.write_member("midiChannel", (int64_t)info.midiChannel);
|
||||||
|
writer.write_member("ccNumber", (int64_t)info.ccNumber);
|
||||||
|
writer.end_object();
|
||||||
|
this->JsonReply(replyTo, "mixerLastLearnedMidiEvent", ss.str().c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerGetLastLearnedMidiEvent)
|
||||||
|
|
||||||
void handle_sha256Base64url(int replyTo, json_reader *pReader)
|
void handle_sha256Base64url(int replyTo, json_reader *pReader)
|
||||||
{
|
{
|
||||||
std::string input;
|
std::string input;
|
||||||
|
|||||||
@@ -0,0 +1,911 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) Robin E.R. Davies
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to
|
||||||
|
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
* of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
* so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "PiPedalCommon.hpp"
|
||||||
|
#include "util.hpp"
|
||||||
|
#include <cmath>
|
||||||
|
#include "Finally.hpp"
|
||||||
|
#include <bit>
|
||||||
|
#include <memory>
|
||||||
|
#include "ss.hpp"
|
||||||
|
#include "PipeWireDriver.hpp"
|
||||||
|
#include "JackServerSettings.hpp"
|
||||||
|
#include <thread>
|
||||||
|
#include "RtInversionGuard.hpp"
|
||||||
|
#include "PiPedalException.hpp"
|
||||||
|
#include "SchedulerPriority.hpp"
|
||||||
|
#include "CrashGuard.hpp"
|
||||||
|
#include <iostream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include "ChannelRouterSettings.hpp"
|
||||||
|
|
||||||
|
#include "CpuUse.hpp"
|
||||||
|
|
||||||
|
#include <pipewire/pipewire.h>
|
||||||
|
#include <pipewire/filter.h>
|
||||||
|
#include <spa/param/audio/format-utils.h>
|
||||||
|
#include <spa/param/props.h>
|
||||||
|
|
||||||
|
#include "Lv2Log.hpp"
|
||||||
|
#include <limits>
|
||||||
|
#include "ss.hpp"
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
namespace pipedal
|
||||||
|
{
|
||||||
|
|
||||||
|
class PipeWireDriverImpl : public AudioDriver
|
||||||
|
{
|
||||||
|
private:
|
||||||
|
// ---- PipeWire state ----
|
||||||
|
pw_filter *filter = nullptr;
|
||||||
|
pw_thread_loop *threadLoop = nullptr;
|
||||||
|
|
||||||
|
void *inputPortData = nullptr;
|
||||||
|
void *outputPortData = nullptr;
|
||||||
|
|
||||||
|
bool pwInitialized = false;
|
||||||
|
|
||||||
|
// ---- Audio parameters ----
|
||||||
|
uint32_t sampleRate = 0;
|
||||||
|
uint32_t bufferSize = 0;
|
||||||
|
uint32_t captureChannels = 0;
|
||||||
|
uint32_t playbackChannels = 0;
|
||||||
|
|
||||||
|
// ---- Buffer management (mirrors AlsaDriver pattern) ----
|
||||||
|
std::vector<std::vector<float>> allocatedBuffers;
|
||||||
|
|
||||||
|
std::vector<float *> deviceCaptureBuffers;
|
||||||
|
std::vector<float *> devicePlaybackBuffers;
|
||||||
|
float *zeroInputBuffer = nullptr;
|
||||||
|
float *discardOutputBuffer = nullptr;
|
||||||
|
std::vector<float *> mainCaptureBuffers;
|
||||||
|
std::vector<float *> mainPlaybackBuffers;
|
||||||
|
std::vector<float *> auxCaptureBuffers;
|
||||||
|
std::vector<float *> auxPlaybackBuffers;
|
||||||
|
|
||||||
|
// ---- Mix ops for channel routing ----
|
||||||
|
using MixOp = std::function<void(size_t nFrames)>;
|
||||||
|
std::vector<MixOp> mixOps;
|
||||||
|
|
||||||
|
void AddMixCopyOp(float *inputBuffer, float *outputBuffer)
|
||||||
|
{
|
||||||
|
mixOps.push_back([inputBuffer, outputBuffer](size_t nFrames)
|
||||||
|
{
|
||||||
|
float * PIPEDAL_RESTRICT pIn = inputBuffer;
|
||||||
|
float * PIPEDAL_RESTRICT pOut = outputBuffer;
|
||||||
|
for (size_t i = 0; i < nFrames; ++i)
|
||||||
|
pOut[i] = pIn[i]; });
|
||||||
|
}
|
||||||
|
void AddMixAddOp(float *inputBuffer, float *outputBuffer)
|
||||||
|
{
|
||||||
|
mixOps.push_back([inputBuffer, outputBuffer](size_t nFrames)
|
||||||
|
{
|
||||||
|
float * PIPEDAL_RESTRICT pIn = inputBuffer;
|
||||||
|
float * PIPEDAL_RESTRICT pOut = outputBuffer;
|
||||||
|
for (size_t i = 0; i < nFrames; ++i)
|
||||||
|
pOut[i] += pIn[i]; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CPU monitoring ----
|
||||||
|
pipedal::CpuUse cpuUse;
|
||||||
|
|
||||||
|
// ---- Lifecycle state ----
|
||||||
|
bool open = false;
|
||||||
|
bool activated = false;
|
||||||
|
bool isDummyDriver = false;
|
||||||
|
|
||||||
|
AudioDriverHost *driverHost = nullptr;
|
||||||
|
ChannelSelection channelSelection;
|
||||||
|
JackServerSettings jackServerSettings;
|
||||||
|
AlsaSequencer::ptr alsaSequencer;
|
||||||
|
|
||||||
|
// ---- MIDI (unused by PipeWire, sequencer handles it) ----
|
||||||
|
std::vector<MidiEvent> midiEvents;
|
||||||
|
size_t midiEventCount = 0;
|
||||||
|
|
||||||
|
public:
|
||||||
|
PipeWireDriverImpl(AudioDriverHost *driverHost)
|
||||||
|
: driverHost(driverHost)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual ~PipeWireDriverImpl()
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// AudioDriver interface implementation
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
virtual float CpuUse() override
|
||||||
|
{
|
||||||
|
return cpuUse.GetCpuUse();
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual float CpuOverhead() override
|
||||||
|
{
|
||||||
|
return cpuUse.GetCpuOverhead();
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetSampleRate() override
|
||||||
|
{
|
||||||
|
return this->sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() const override
|
||||||
|
{
|
||||||
|
return this->captureChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() const override
|
||||||
|
{
|
||||||
|
return this->playbackChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual size_t GetMidiInputEventCount() override
|
||||||
|
{
|
||||||
|
return midiEventCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual MidiEvent *GetMidiEvents() override
|
||||||
|
{
|
||||||
|
return this->midiEvents.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual const ChannelSelection &GetChannelSelection() const override
|
||||||
|
{
|
||||||
|
return channelSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &DeviceInputBuffers() override { return this->deviceCaptureBuffers; }
|
||||||
|
virtual size_t DeviceInputBufferCount() const override { return deviceCaptureBuffers.size(); }
|
||||||
|
virtual float *GetDeviceInputBuffer(size_t channel) const override
|
||||||
|
{
|
||||||
|
if (channel >= deviceCaptureBuffers.size())
|
||||||
|
return nullptr;
|
||||||
|
return deviceCaptureBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &DeviceOutputBuffers() override { return this->devicePlaybackBuffers; }
|
||||||
|
virtual size_t DeviceOutputBufferCount() const override { return devicePlaybackBuffers.size(); }
|
||||||
|
virtual float *GetDeviceOutputBuffer(size_t channel) const override
|
||||||
|
{
|
||||||
|
if (channel >= devicePlaybackBuffers.size())
|
||||||
|
return nullptr;
|
||||||
|
return devicePlaybackBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &MainInputBuffers() override { return this->mainCaptureBuffers; }
|
||||||
|
virtual size_t MainInputBufferCount() const override { return mainCaptureBuffers.size(); }
|
||||||
|
virtual float *GetMainInputBuffer(size_t channel) override
|
||||||
|
{
|
||||||
|
if (channel >= (int64_t)mainCaptureBuffers.size())
|
||||||
|
throw std::runtime_error("Argument out of range.");
|
||||||
|
return mainCaptureBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &MainOutputBuffers() override { return this->mainPlaybackBuffers; }
|
||||||
|
virtual size_t MainOutputBufferCount() const override { return mainPlaybackBuffers.size(); }
|
||||||
|
virtual float *GetMainOutputBuffer(size_t channel) override
|
||||||
|
{
|
||||||
|
return mainPlaybackBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &AuxInputBuffers() override { return this->auxCaptureBuffers; }
|
||||||
|
virtual size_t AuxInputBufferCount() const override { return auxCaptureBuffers.size(); }
|
||||||
|
virtual float *GetAuxInputBuffer(size_t channel) override
|
||||||
|
{
|
||||||
|
return auxCaptureBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &AuxOutputBuffers() override { return this->auxPlaybackBuffers; }
|
||||||
|
virtual size_t AuxOutputBufferCount() const override { return auxPlaybackBuffers.size(); }
|
||||||
|
virtual float *GetAuxOutputBuffer(size_t channel) override
|
||||||
|
{
|
||||||
|
return auxPlaybackBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual float *GetZeroInputBuffer() override
|
||||||
|
{
|
||||||
|
if (zeroInputBuffer == nullptr)
|
||||||
|
zeroInputBuffer = AllocateAudioBuffer();
|
||||||
|
return zeroInputBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual float *GetDiscardOutputBuffer() override
|
||||||
|
{
|
||||||
|
if (discardOutputBuffer == nullptr)
|
||||||
|
discardOutputBuffer = AllocateAudioBuffer();
|
||||||
|
return discardOutputBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Open - Initialize PipeWire and create the filter
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void Open(const JackServerSettings &jackServerSettings_,
|
||||||
|
const ChannelSelection &channelSelection_) override
|
||||||
|
{
|
||||||
|
if (open)
|
||||||
|
throw PiPedalStateException("Already open.");
|
||||||
|
|
||||||
|
this->jackServerSettings = jackServerSettings_;
|
||||||
|
this->channelSelection = channelSelection_;
|
||||||
|
this->isDummyDriver = jackServerSettings_.IsDummyAudioDevice();
|
||||||
|
this->bufferSize = jackServerSettings_.GetBufferSize();
|
||||||
|
this->sampleRate = (uint32_t)jackServerSettings_.GetSampleRate();
|
||||||
|
|
||||||
|
if (this->sampleRate == 0)
|
||||||
|
this->sampleRate = 48000;
|
||||||
|
if (this->bufferSize == 0)
|
||||||
|
this->bufferSize = 256;
|
||||||
|
|
||||||
|
open = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!pwInitialized)
|
||||||
|
{
|
||||||
|
pw_init(nullptr, nullptr);
|
||||||
|
pwInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create thread loop for non-blocking lifecycle management
|
||||||
|
threadLoop = pw_thread_loop_new("pipedal-pw-loop", nullptr);
|
||||||
|
if (!threadLoop)
|
||||||
|
{
|
||||||
|
throw PiPedalStateException("Failed to create PipeWire thread loop.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filter using the thread loop's pw_loop
|
||||||
|
struct pw_properties *props = pw_properties_new(
|
||||||
|
PW_KEY_NODE_NAME, "PiPedal",
|
||||||
|
PW_KEY_NODE_DESCRIPTION, "PiPedal Guitar Effects Processor",
|
||||||
|
PW_KEY_MEDIA_NAME, "PiPedal Audio",
|
||||||
|
PW_KEY_MEDIA_TYPE, "Audio",
|
||||||
|
PW_KEY_MEDIA_CATEGORY, "Filter",
|
||||||
|
PW_KEY_MEDIA_CLASS, "Audio/Sink",
|
||||||
|
PW_KEY_APP_NAME, "PiPedal",
|
||||||
|
PW_KEY_APP_PROCESS_BINARY, "pipedald",
|
||||||
|
PW_KEY_PRIORITY_SESSION, "0",
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
static const struct pw_filter_events filterEvents = {
|
||||||
|
.version = PW_VERSION_FILTER_EVENTS,
|
||||||
|
.state_changed = on_filter_state_changed_static,
|
||||||
|
.process = on_filter_process_static,
|
||||||
|
};
|
||||||
|
|
||||||
|
filter = pw_filter_new_simple(
|
||||||
|
pw_thread_loop_get_loop(threadLoop),
|
||||||
|
"PiPedal",
|
||||||
|
props,
|
||||||
|
&filterEvents,
|
||||||
|
this);
|
||||||
|
|
||||||
|
if (!filter)
|
||||||
|
{
|
||||||
|
pw_thread_loop_destroy(threadLoop);
|
||||||
|
threadLoop = nullptr;
|
||||||
|
throw PiPedalStateException("Failed to create PipeWire filter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine channel count from channel selection
|
||||||
|
captureChannels = (uint32_t)channelSelection_.mainInputChannels().size();
|
||||||
|
playbackChannels = (uint32_t)channelSelection_.mainOutputChannels().size();
|
||||||
|
|
||||||
|
if (captureChannels == 0)
|
||||||
|
captureChannels = 2; // default stereo
|
||||||
|
if (playbackChannels == 0)
|
||||||
|
playbackChannels = 2;
|
||||||
|
|
||||||
|
// Build audio format params for input (capture) port
|
||||||
|
{
|
||||||
|
uint8_t buffer[1024];
|
||||||
|
spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
|
||||||
|
|
||||||
|
spa_audio_info_raw audioFormat = {};
|
||||||
|
audioFormat.format = SPA_AUDIO_FORMAT_F32;
|
||||||
|
audioFormat.rate = this->sampleRate;
|
||||||
|
audioFormat.channels = captureChannels;
|
||||||
|
|
||||||
|
// Set channel positions
|
||||||
|
SetChannelPositions(audioFormat, captureChannels);
|
||||||
|
|
||||||
|
const spa_pod *params[1];
|
||||||
|
params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &audioFormat);
|
||||||
|
|
||||||
|
inputPortData = pw_filter_add_port(
|
||||||
|
filter,
|
||||||
|
PW_DIRECTION_INPUT,
|
||||||
|
PW_FILTER_PORT_FLAG_MAP_BUFFERS,
|
||||||
|
sizeof(void *),
|
||||||
|
pw_properties_new(
|
||||||
|
PW_KEY_PORT_NAME, "Input",
|
||||||
|
PW_KEY_AUDIO_CHANNELS, std::to_string(captureChannels).c_str(),
|
||||||
|
nullptr),
|
||||||
|
params, 1);
|
||||||
|
|
||||||
|
if (!inputPortData)
|
||||||
|
{
|
||||||
|
pw_filter_destroy(filter);
|
||||||
|
filter = nullptr;
|
||||||
|
pw_thread_loop_destroy(threadLoop);
|
||||||
|
threadLoop = nullptr;
|
||||||
|
throw PiPedalStateException("Failed to add PipeWire input port.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build audio format params for output (playback) port
|
||||||
|
{
|
||||||
|
uint8_t buffer[1024];
|
||||||
|
spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
|
||||||
|
|
||||||
|
spa_audio_info_raw audioFormat = {};
|
||||||
|
audioFormat.format = SPA_AUDIO_FORMAT_F32;
|
||||||
|
audioFormat.rate = this->sampleRate;
|
||||||
|
audioFormat.channels = playbackChannels;
|
||||||
|
|
||||||
|
SetChannelPositions(audioFormat, playbackChannels);
|
||||||
|
|
||||||
|
const spa_pod *params[1];
|
||||||
|
params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &audioFormat);
|
||||||
|
|
||||||
|
outputPortData = pw_filter_add_port(
|
||||||
|
filter,
|
||||||
|
PW_DIRECTION_OUTPUT,
|
||||||
|
PW_FILTER_PORT_FLAG_MAP_BUFFERS,
|
||||||
|
sizeof(void *),
|
||||||
|
pw_properties_new(
|
||||||
|
PW_KEY_PORT_NAME, "Output",
|
||||||
|
PW_KEY_AUDIO_CHANNELS, std::to_string(playbackChannels).c_str(),
|
||||||
|
nullptr),
|
||||||
|
params, 1);
|
||||||
|
|
||||||
|
if (!outputPortData)
|
||||||
|
{
|
||||||
|
pw_filter_destroy(filter);
|
||||||
|
filter = nullptr;
|
||||||
|
pw_thread_loop_destroy(threadLoop);
|
||||||
|
threadLoop = nullptr;
|
||||||
|
throw PiPedalStateException("Failed to add PipeWire output port.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect the filter to the PipeWire graph
|
||||||
|
int res = pw_filter_connect(
|
||||||
|
filter,
|
||||||
|
PW_FILTER_FLAG_RT_PROCESS,
|
||||||
|
nullptr, 0);
|
||||||
|
|
||||||
|
if (res < 0)
|
||||||
|
{
|
||||||
|
pw_filter_destroy(filter);
|
||||||
|
filter = nullptr;
|
||||||
|
pw_thread_loop_destroy(threadLoop);
|
||||||
|
threadLoop = nullptr;
|
||||||
|
throw PiPedalStateException(
|
||||||
|
std::string("Failed to connect PipeWire filter: ") + strerror(-res));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the thread loop
|
||||||
|
pw_thread_loop_start(threadLoop);
|
||||||
|
|
||||||
|
Lv2Log::info(SS("PipeWire driver opened: " << captureChannels
|
||||||
|
<< " capture channels, "
|
||||||
|
<< playbackChannels
|
||||||
|
<< " playback channels, "
|
||||||
|
<< this->sampleRate << "Hz, "
|
||||||
|
<< this->bufferSize << " frames"));
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Activate - allocate buffers and start processing
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void Activate() override
|
||||||
|
{
|
||||||
|
if (activated)
|
||||||
|
throw PiPedalStateException("Already activated.");
|
||||||
|
|
||||||
|
activated = true;
|
||||||
|
|
||||||
|
// Reset previously allocated buffers
|
||||||
|
allocatedBuffers.resize(0);
|
||||||
|
|
||||||
|
// Allocate device capture buffers
|
||||||
|
zeroInputBuffer = AllocateAudioBuffer();
|
||||||
|
deviceCaptureBuffers.resize(captureChannels);
|
||||||
|
for (size_t i = 0; i < captureChannels; ++i)
|
||||||
|
{
|
||||||
|
deviceCaptureBuffers[i] = AllocateAudioBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate device playback buffers
|
||||||
|
devicePlaybackBuffers.resize(playbackChannels);
|
||||||
|
for (size_t i = 0; i < playbackChannels; ++i)
|
||||||
|
{
|
||||||
|
devicePlaybackBuffers[i] = AllocateAudioBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate input channel routing (main)
|
||||||
|
AllocateInputChannels(
|
||||||
|
channelSelection.mainInputChannels(),
|
||||||
|
this->mainCaptureBuffers);
|
||||||
|
|
||||||
|
// Allocate output channel routing (main)
|
||||||
|
AllocateOutputChannels(
|
||||||
|
channelSelection.mainOutputChannels(),
|
||||||
|
this->mainPlaybackBuffers);
|
||||||
|
|
||||||
|
// Allocate aux channels
|
||||||
|
AllocateAuxChannels();
|
||||||
|
|
||||||
|
// Set up mix operations for channel routing
|
||||||
|
AddMixOps();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Deactivate - stop processing
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void Deactivate() override
|
||||||
|
{
|
||||||
|
if (!activated)
|
||||||
|
return;
|
||||||
|
|
||||||
|
activated = false;
|
||||||
|
|
||||||
|
// The pw_filter will continue to call process but we won't
|
||||||
|
// do anything since activated is false. The PipeWire graph
|
||||||
|
// will eventually stop scheduling us.
|
||||||
|
|
||||||
|
// Clear mix ops
|
||||||
|
mixOps.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Close - tear down PipeWire resources
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void Close() override
|
||||||
|
{
|
||||||
|
if (!open)
|
||||||
|
return;
|
||||||
|
open = false;
|
||||||
|
|
||||||
|
Deactivate();
|
||||||
|
|
||||||
|
// Clean up PipeWire filter
|
||||||
|
if (filter)
|
||||||
|
{
|
||||||
|
pw_filter_disconnect(filter);
|
||||||
|
pw_filter_destroy(filter);
|
||||||
|
filter = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop and destroy thread loop
|
||||||
|
if (threadLoop)
|
||||||
|
{
|
||||||
|
pw_thread_loop_stop(threadLoop);
|
||||||
|
pw_thread_loop_destroy(threadLoop);
|
||||||
|
threadLoop = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deinitialize PipeWire (only if we initialized it)
|
||||||
|
if (pwInitialized)
|
||||||
|
{
|
||||||
|
// pw_deinit(); // Note: we don't call this to avoid issues with
|
||||||
|
// other PipeWire users in the process. It's safe to leave initialized.
|
||||||
|
}
|
||||||
|
|
||||||
|
DeleteBuffers();
|
||||||
|
this->alsaSequencer = nullptr;
|
||||||
|
|
||||||
|
Lv2Log::info("PipeWire driver closed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// SetAlsaSequencer - store for MIDI routing (not used by PipeWire)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void SetAlsaSequencer(AlsaSequencer::ptr alsaSequencer) override
|
||||||
|
{
|
||||||
|
this->alsaSequencer = alsaSequencer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// GetConfigurationDescription - human-readable description
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual std::string GetConfigurationDescription() override
|
||||||
|
{
|
||||||
|
std::stringstream s;
|
||||||
|
s << "PipeWire: " << captureChannels << " in, "
|
||||||
|
<< playbackChannels << " out, "
|
||||||
|
<< this->sampleRate << " Hz, "
|
||||||
|
<< this->bufferSize << " frames";
|
||||||
|
return s.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// DumpBufferTrace - stub (no PipeWire equivalent)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void DumpBufferTrace(size_t nEntries) override
|
||||||
|
{
|
||||||
|
// Not implemented for PipeWire driver
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Static PipeWire filter event callbacks
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
static void on_filter_process_static(void *data, struct spa_io_position *position)
|
||||||
|
{
|
||||||
|
auto *self = static_cast<PipeWireDriverImpl *>(data);
|
||||||
|
self->on_filter_process(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_filter_state_changed_static(
|
||||||
|
void *data,
|
||||||
|
enum pw_filter_state old,
|
||||||
|
enum pw_filter_state state,
|
||||||
|
const char *error)
|
||||||
|
{
|
||||||
|
auto *self = static_cast<PipeWireDriverImpl *>(data);
|
||||||
|
self->on_filter_state_changed(old, state, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Filter state change handler
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
void on_filter_state_changed(
|
||||||
|
enum pw_filter_state old,
|
||||||
|
enum pw_filter_state state,
|
||||||
|
const char *error)
|
||||||
|
{
|
||||||
|
if (state == PW_FILTER_STATE_ERROR)
|
||||||
|
{
|
||||||
|
Lv2Log::error(SS("PipeWire filter error: " << (error ? error : "unknown")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Main processing callback - called from PipeWire RT thread
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
void on_filter_process(struct spa_io_position *position)
|
||||||
|
{
|
||||||
|
if (!activated)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pw_buffer *inBuf = nullptr;
|
||||||
|
pw_buffer *outBuf = nullptr;
|
||||||
|
|
||||||
|
// Dequeue input buffer (capture)
|
||||||
|
inBuf = (pw_buffer *)pw_filter_dequeue_buffer(inputPortData);
|
||||||
|
if (!inBuf)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Dequeue output buffer (playback)
|
||||||
|
outBuf = (pw_buffer *)pw_filter_dequeue_buffer(outputPortData);
|
||||||
|
if (!outBuf)
|
||||||
|
{
|
||||||
|
pw_filter_queue_buffer(inputPortData, inBuf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
spa_buffer *spaIn = inBuf->buffer;
|
||||||
|
spa_buffer *spaOut = outBuf->buffer;
|
||||||
|
|
||||||
|
uint32_t nInputChannels = std::min(spaIn->n_datas, captureChannels);
|
||||||
|
uint32_t nOutputChannels = std::min(spaOut->n_datas, playbackChannels);
|
||||||
|
|
||||||
|
// Determine frame count from the input buffer data size
|
||||||
|
uint32_t nFrames = 0;
|
||||||
|
if (spaIn->n_datas > 0 && spaIn->datas[0].chunk)
|
||||||
|
{
|
||||||
|
nFrames = spaIn->datas[0].chunk->size / sizeof(float);
|
||||||
|
if (nInputChannels > 1)
|
||||||
|
nFrames /= nInputChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nFrames == 0)
|
||||||
|
nFrames = this->bufferSize;
|
||||||
|
|
||||||
|
// Clamp to our buffer size
|
||||||
|
if (nFrames > this->bufferSize)
|
||||||
|
nFrames = this->bufferSize;
|
||||||
|
|
||||||
|
// ---- Read input from PipeWire ----
|
||||||
|
// Copy input data from PipeWire buffers to our device capture buffers.
|
||||||
|
// PipeWire uses planar (non-interleaved) format where each data chunk is one channel.
|
||||||
|
for (uint32_t ch = 0; ch < nInputChannels; ++ch)
|
||||||
|
{
|
||||||
|
if (ch < deviceCaptureBuffers.size() && spaIn->datas[ch].data != nullptr)
|
||||||
|
{
|
||||||
|
float *src = (float *)((uint8_t *)spaIn->datas[ch].data +
|
||||||
|
(spaIn->datas[ch].chunk ? spaIn->datas[ch].chunk->offset : 0));
|
||||||
|
float *dst = deviceCaptureBuffers[ch];
|
||||||
|
for (uint32_t i = 0; i < nFrames; ++i)
|
||||||
|
{
|
||||||
|
dst[i] = src[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero out any remaining input channels that PipeWire didn't fill
|
||||||
|
for (uint32_t ch = nInputChannels; ch < captureChannels; ++ch)
|
||||||
|
{
|
||||||
|
if (ch < deviceCaptureBuffers.size())
|
||||||
|
{
|
||||||
|
float *dst = deviceCaptureBuffers[ch];
|
||||||
|
memset(dst, 0, nFrames * sizeof(float));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Channel routing (input) ----
|
||||||
|
for (auto &mixOp : mixOps)
|
||||||
|
{
|
||||||
|
mixOp(nFrames);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Process audio via the host ----
|
||||||
|
// This is where PiPedal's DSP pipeline runs (on the RT thread)
|
||||||
|
driverHost->OnProcess(nFrames);
|
||||||
|
|
||||||
|
// ---- Write output to PipeWire ----
|
||||||
|
for (uint32_t ch = 0; ch < nOutputChannels; ++ch)
|
||||||
|
{
|
||||||
|
if (ch < devicePlaybackBuffers.size() && spaOut->datas[ch].data != nullptr)
|
||||||
|
{
|
||||||
|
float *dst = (float *)((uint8_t *)spaOut->datas[ch].data +
|
||||||
|
(spaOut->datas[ch].chunk ? spaOut->datas[ch].chunk->offset : 0));
|
||||||
|
float *src = devicePlaybackBuffers[ch];
|
||||||
|
uint32_t copyFrames = std::min(nFrames,
|
||||||
|
(uint32_t)(spaOut->datas[ch].chunk ?
|
||||||
|
spaOut->datas[ch].chunk->size / sizeof(float) : nFrames));
|
||||||
|
for (uint32_t i = 0; i < copyFrames; ++i)
|
||||||
|
{
|
||||||
|
dst[i] = src[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue both buffers back to PipeWire
|
||||||
|
pw_filter_queue_buffer(outputPortData, outBuf);
|
||||||
|
pw_filter_queue_buffer(inputPortData, inBuf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Buffer allocation helpers (mirrors AlsaDriver pattern)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
float *AllocateAudioBuffer()
|
||||||
|
{
|
||||||
|
std::vector<float> buffer;
|
||||||
|
buffer.resize(this->bufferSize);
|
||||||
|
float *pBuffer = buffer.data();
|
||||||
|
allocatedBuffers.push_back(std::move(buffer));
|
||||||
|
return pBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeleteBuffers()
|
||||||
|
{
|
||||||
|
mainCaptureBuffers.clear();
|
||||||
|
mainPlaybackBuffers.clear();
|
||||||
|
auxCaptureBuffers.clear();
|
||||||
|
auxPlaybackBuffers.clear();
|
||||||
|
deviceCaptureBuffers.clear();
|
||||||
|
devicePlaybackBuffers.clear();
|
||||||
|
zeroInputBuffer = nullptr;
|
||||||
|
discardOutputBuffer = nullptr;
|
||||||
|
allocatedBuffers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Channel allocation helpers (mirrors AlsaDriver pattern)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
void AllocateInputChannels(
|
||||||
|
const std::vector<int64_t> &channelSelection,
|
||||||
|
std::vector<float *> &channelBuffers)
|
||||||
|
{
|
||||||
|
size_t nChannels = channelSelection.size();
|
||||||
|
if (nChannels == 0)
|
||||||
|
{
|
||||||
|
channelBuffers.resize(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
channelBuffers.resize(nChannels);
|
||||||
|
for (size_t i = 0; i < nChannels; ++i)
|
||||||
|
{
|
||||||
|
int64_t deviceChannel = channelSelection[i];
|
||||||
|
if (deviceChannel == -1 || (size_t)deviceChannel >= captureChannels)
|
||||||
|
{
|
||||||
|
channelBuffers[i] = zeroInputBuffer;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
channelBuffers[i] = deviceCaptureBuffers[deviceChannel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AllocateOutputChannels(
|
||||||
|
const std::vector<int64_t> &channelSelection,
|
||||||
|
std::vector<float *> &channelBuffers)
|
||||||
|
{
|
||||||
|
size_t nChannels = channelSelection.size();
|
||||||
|
if (nChannels == 0)
|
||||||
|
{
|
||||||
|
channelBuffers.resize(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
channelBuffers.resize(nChannels);
|
||||||
|
for (size_t i = 0; i < nChannels; ++i)
|
||||||
|
{
|
||||||
|
int64_t deviceChannel = channelSelection[i];
|
||||||
|
if (deviceChannel == -1)
|
||||||
|
{
|
||||||
|
channelBuffers[i] = this->GetDiscardOutputBuffer();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float *mixBuffer = AllocateAudioBuffer();
|
||||||
|
channelBuffers[i] = mixBuffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AllocateAuxChannels()
|
||||||
|
{
|
||||||
|
for (auto ix : channelSelection.auxInputChannels())
|
||||||
|
{
|
||||||
|
if ((size_t)ix < deviceCaptureBuffers.size())
|
||||||
|
auxCaptureBuffers.push_back(this->deviceCaptureBuffers[ix]);
|
||||||
|
else
|
||||||
|
auxCaptureBuffers.push_back(this->zeroInputBuffer);
|
||||||
|
}
|
||||||
|
for (auto ix : channelSelection.auxOutputChannels())
|
||||||
|
{
|
||||||
|
if ((size_t)ix < devicePlaybackBuffers.size())
|
||||||
|
auxPlaybackBuffers.push_back(this->devicePlaybackBuffers[ix]);
|
||||||
|
else
|
||||||
|
auxPlaybackBuffers.push_back(this->GetDiscardOutputBuffer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Mix operations for channel routing (mirrors AlsaDriver)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
void AddMixOps()
|
||||||
|
{
|
||||||
|
// Main input: copy from device capture channels to main capture channels
|
||||||
|
for (size_t i = 0; i < mainCaptureBuffers.size(); ++i)
|
||||||
|
{
|
||||||
|
if (mainCaptureBuffers[i] != deviceCaptureBuffers[channelSelection.mainInputChannels()[i]] &&
|
||||||
|
mainCaptureBuffers[i] != zeroInputBuffer)
|
||||||
|
{
|
||||||
|
// Direct pointer, no copy needed (already set up in AllocateInputChannels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main output: copy from main playback to device playback channels
|
||||||
|
for (size_t i = 0; i < mainPlaybackBuffers.size(); ++i)
|
||||||
|
{
|
||||||
|
int64_t deviceChannel = channelSelection.mainOutputChannels()[i];
|
||||||
|
if (deviceChannel >= 0 && (size_t)deviceChannel < devicePlaybackBuffers.size())
|
||||||
|
{
|
||||||
|
if (mainPlaybackBuffers[i] != devicePlaybackBuffers[deviceChannel])
|
||||||
|
{
|
||||||
|
// Mix copy: main playback buffer → device playback buffer
|
||||||
|
AddMixCopyOp(mainPlaybackBuffers[i], devicePlaybackBuffers[deviceChannel]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Channel position helper for SPA audio format
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
static void SetChannelPositions(spa_audio_info_raw &format, uint32_t channels)
|
||||||
|
{
|
||||||
|
switch (channels)
|
||||||
|
{
|
||||||
|
case 1:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_MONO;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
format.position[3] = SPA_AUDIO_CHANNEL_LFE;
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
format.position[3] = SPA_AUDIO_CHANNEL_LFE;
|
||||||
|
format.position[4] = SPA_AUDIO_CHANNEL_RL;
|
||||||
|
break;
|
||||||
|
case 6:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
format.position[3] = SPA_AUDIO_CHANNEL_LFE;
|
||||||
|
format.position[4] = SPA_AUDIO_CHANNEL_RL;
|
||||||
|
format.position[5] = SPA_AUDIO_CHANNEL_RR;
|
||||||
|
break;
|
||||||
|
case 7:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
format.position[3] = SPA_AUDIO_CHANNEL_LFE;
|
||||||
|
format.position[4] = SPA_AUDIO_CHANNEL_RL;
|
||||||
|
format.position[5] = SPA_AUDIO_CHANNEL_RR;
|
||||||
|
format.position[6] = SPA_AUDIO_CHANNEL_SL;
|
||||||
|
break;
|
||||||
|
case 8:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
format.position[3] = SPA_AUDIO_CHANNEL_LFE;
|
||||||
|
format.position[4] = SPA_AUDIO_CHANNEL_RL;
|
||||||
|
format.position[5] = SPA_AUDIO_CHANNEL_RR;
|
||||||
|
format.position[6] = SPA_AUDIO_CHANNEL_SL;
|
||||||
|
format.position[7] = SPA_AUDIO_CHANNEL_SR;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// For > 8 channels, assign Aux channels
|
||||||
|
for (uint32_t i = 0; i < channels && i < SPA_AUDIO_MAX_CHANNELS; ++i)
|
||||||
|
{
|
||||||
|
format.position[i] = SPA_AUDIO_CHANNEL_AUX0 + i;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Factory function
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
AudioDriver *CreatePipeWireDriver(AudioDriverHost *driverHost)
|
||||||
|
{
|
||||||
|
return new PipeWireDriverImpl(driverHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) Robin E.R. Davies
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to
|
||||||
|
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
* of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
* so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "AudioDriver.hpp"
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
AudioDriver* CreatePipeWireDriver(AudioDriverHost* driverHost);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
#include "WebServerConfig.hpp"
|
#include "WebServerConfig.hpp"
|
||||||
#include <execinfo.h>
|
#include <execinfo.h>
|
||||||
#include "PiPedalSocket.hpp"
|
#include "PiPedalSocket.hpp"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
#include "PluginHost.hpp"
|
#include "PluginHost.hpp"
|
||||||
#include <boost/system/error_code.hpp>
|
#include <boost/system/error_code.hpp>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
@@ -185,6 +186,7 @@ int main(int argc, char *argv[])
|
|||||||
bool testExtraDevice = false;
|
bool testExtraDevice = false;
|
||||||
std::string logLevel;
|
std::string logLevel;
|
||||||
std::string portOption;
|
std::string portOption;
|
||||||
|
std::string driverOption = "alsa";
|
||||||
|
|
||||||
CommandLineParser parser;
|
CommandLineParser parser;
|
||||||
parser.AddOption("-h", &help);
|
parser.AddOption("-h", &help);
|
||||||
@@ -193,6 +195,7 @@ int main(int argc, char *argv[])
|
|||||||
parser.AddOption("-log-level", &logLevel);
|
parser.AddOption("-log-level", &logLevel);
|
||||||
parser.AddOption("-port", &portOption);
|
parser.AddOption("-port", &portOption);
|
||||||
parser.AddOption("-test-extra-device", &testExtraDevice); // advertise two different devices (for testing multi-device connect)
|
parser.AddOption("-test-extra-device", &testExtraDevice); // advertise two different devices (for testing multi-device connect)
|
||||||
|
parser.AddOption("--driver", &driverOption);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -226,6 +229,7 @@ int main(int argc, char *argv[])
|
|||||||
<< " -systemd: Log to systemd journals instead of to the console.\n"
|
<< " -systemd: Log to systemd journals instead of to the console.\n"
|
||||||
<< " -port: Port to listen on e.g. 80, or 0.0.0.0:80\n"
|
<< " -port: Port to listen on e.g. 80, or 0.0.0.0:80\n"
|
||||||
<< " -log-level: (debug|info|warning|error)\n"
|
<< " -log-level: (debug|info|warning|error)\n"
|
||||||
|
<< " --driver: Audio driver (alsa|pipewire, default: alsa)\n"
|
||||||
<< "Example:\n"
|
<< "Example:\n"
|
||||||
<< " pipedald /etc/pipedal/config /etc/pipedal/react -port 80 \n"
|
<< " pipedald /etc/pipedal/config /etc/pipedal/react -port 80 \n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -398,6 +402,7 @@ int main(int argc, char *argv[])
|
|||||||
});
|
});
|
||||||
|
|
||||||
model.Init(configuration);
|
model.Init(configuration);
|
||||||
|
model.SetDriverType(driverOption);
|
||||||
|
|
||||||
// Get heavy IO out of the way before letting dependent (Jack/ALSA) services run.
|
// Get heavy IO out of the way before letting dependent (Jack/ALSA) services run.
|
||||||
model.LoadLv2PluginInfo();
|
model.LoadLv2PluginInfo();
|
||||||
@@ -447,6 +452,18 @@ int main(int argc, char *argv[])
|
|||||||
|
|
||||||
model.Load();
|
model.Load();
|
||||||
|
|
||||||
|
// Initialize Band-in-a-Box mixer engine
|
||||||
|
{
|
||||||
|
auto mixerEngine = std::make_shared<MixerEngine>();
|
||||||
|
// Configure with current audio settings
|
||||||
|
auto jackSettings = model.GetJackServerSettings();
|
||||||
|
mixerEngine->setSampleRate((uint32_t)jackSettings.GetSampleRate());
|
||||||
|
mixerEngine->setMaxBufferSize((size_t)jackSettings.GetBufferSize());
|
||||||
|
mixerEngine->Activate();
|
||||||
|
model.SetMixerEngine(mixerEngine);
|
||||||
|
Lv2Log::info("MixerEngine initialized (BB-6)");
|
||||||
|
}
|
||||||
|
|
||||||
auto pipedalSocketFactory = MakePiPedalSocketFactory(model);
|
auto pipedalSocketFactory = MakePiPedalSocketFactory(model);
|
||||||
|
|
||||||
server->AddSocketFactory(pipedalSocketFactory);
|
server->AddSocketFactory(pipedalSocketFactory);
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ import SettingsIcon from './svg/ic_settings.svg?react';
|
|||||||
import HelpOutlineIcon from './svg/ic_help_outline.svg?react';
|
import HelpOutlineIcon from './svg/ic_help_outline.svg?react';
|
||||||
import FxAmplifierIcon from './svg/fx_amplifier.svg?react';
|
import FxAmplifierIcon from './svg/fx_amplifier.svg?react';
|
||||||
import { PerformanceView } from './PerformanceView';
|
import { PerformanceView } from './PerformanceView';
|
||||||
|
import MusicNoteIcon from '@mui/icons-material/MusicNote';
|
||||||
|
import MixerPage from './mixer/MixerPage';
|
||||||
|
|
||||||
|
|
||||||
import DialogEx from './DialogEx';
|
import DialogEx from './DialogEx';
|
||||||
@@ -322,6 +324,7 @@ type AppState = {
|
|||||||
banks: BankIndex;
|
banks: BankIndex;
|
||||||
bankDisplayItems: number;
|
bankDisplayItems: number;
|
||||||
showStatusMonitor: boolean;
|
showStatusMonitor: boolean;
|
||||||
|
mixerView: boolean;
|
||||||
};
|
};
|
||||||
class MenuStackHandler implements IDialogStackable {
|
class MenuStackHandler implements IDialogStackable {
|
||||||
constructor(app: AppThemedBase) {
|
constructor(app: AppThemedBase) {
|
||||||
@@ -380,7 +383,8 @@ export
|
|||||||
editBankDialogOpen: false,
|
editBankDialogOpen: false,
|
||||||
zoomedControlOpen: false,
|
zoomedControlOpen: false,
|
||||||
bankDisplayItems: 5,
|
bankDisplayItems: 5,
|
||||||
showStatusMonitor: this.model_.showStatusMonitor.get()
|
showStatusMonitor: this.model_.showStatusMonitor.get(),
|
||||||
|
mixerView: false,
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -802,6 +806,8 @@ export
|
|||||||
<PerformanceView open={this.state.performanceView}
|
<PerformanceView open={this.state.performanceView}
|
||||||
onClose={() => { this.setState({ performanceView: false }); }}
|
onClose={() => { this.setState({ performanceView: false }); }}
|
||||||
/>
|
/>
|
||||||
|
) : this.state.mixerView ? (
|
||||||
|
<MixerPage onClose={() => { this.setState({ mixerView: false }); }} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute", width: "100%", height: "100%", userSelect: "none",
|
position: "absolute", width: "100%", height: "100%", userSelect: "none",
|
||||||
@@ -890,6 +896,17 @@ export
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary='Performance View' />
|
<ListItemText primary='Performance View' />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
<ListItemButton key='MixerView'
|
||||||
|
onClick={(ev: any) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.hideDrawer(true);
|
||||||
|
this.setState({ mixerView: true });
|
||||||
|
}}>
|
||||||
|
<ListItemIcon >
|
||||||
|
<MusicNoteIcon color='inherit' className={classes.menuIcon} style={{ width: 24, height: 24 }} />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary='Mixer Console' />
|
||||||
|
</ListItemButton>
|
||||||
</List>
|
</List>
|
||||||
<Divider />
|
<Divider />
|
||||||
<ListSubheader className="listSubheader" component="div" id="xnested-list-subheader" style={{ lineHeight: "24px", height: 24, background: "rgba(12,12,12,0.0)" }}
|
<ListSubheader className="listSubheader" component="div" id="xnested-list-subheader" style={{ lineHeight: "24px", height: 24, background: "rgba(12,12,12,0.0)" }}
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
// MixerScenePanel — Scene management for the digital mixer.
|
||||||
|
//
|
||||||
|
// Displays 8 numbered scene slots (like a hardware digital mixer).
|
||||||
|
// Each slot can store/recall a full mixer state snapshot.
|
||||||
|
// Uses backend mixerSaveScene / mixerLoadScene / mixerGetScenes WebSocket messages.
|
||||||
|
//
|
||||||
|
// The panel can be embedded in the PerformanceView or used standalone.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
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 SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import RestoreIcon from '@mui/icons-material/Restore';
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
|
||||||
|
|
||||||
|
export const MAX_SCENES = 8;
|
||||||
|
|
||||||
|
export interface MixerSceneInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixerScenePanelProps {
|
||||||
|
/** If true, renders without external chrome (for embedding) */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixerScenePanelState {
|
||||||
|
scenes: MixerSceneInfo[];
|
||||||
|
selectedId: number | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
/** Save dialog state */
|
||||||
|
saveDialogOpen: boolean;
|
||||||
|
saveName: string;
|
||||||
|
saving: boolean;
|
||||||
|
/** Confirm dialog for load */
|
||||||
|
confirmLoadId: number | null;
|
||||||
|
confirmLoadName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class MixerScenePanel extends React.Component<MixerScenePanelProps, MixerScenePanelState> {
|
||||||
|
private model: PiPedalModel;
|
||||||
|
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(props: MixerScenePanelProps) {
|
||||||
|
super(props);
|
||||||
|
this.model = PiPedalModelFactory.getInstance();
|
||||||
|
this.state = {
|
||||||
|
scenes: [],
|
||||||
|
selectedId: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
saveDialogOpen: false,
|
||||||
|
saveName: '',
|
||||||
|
saving: false,
|
||||||
|
confirmLoadId: null,
|
||||||
|
confirmLoadName: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.refreshScenes = this.refreshScenes.bind(this);
|
||||||
|
this.handleSaveClick = this.handleSaveClick.bind(this);
|
||||||
|
this.handleSaveDialogClose = this.handleSaveDialogClose.bind(this);
|
||||||
|
this.handleSaveConfirm = this.handleSaveConfirm.bind(this);
|
||||||
|
this.handleSlotClick = this.handleSlotClick.bind(this);
|
||||||
|
this.handleLoadConfirm = this.handleLoadConfirm.bind(this);
|
||||||
|
this.handleLoadCancel = this.handleLoadCancel.bind(this);
|
||||||
|
this.handleDeleteClick = this.handleDeleteClick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.refreshScenes();
|
||||||
|
// Poll for scene updates from other clients
|
||||||
|
this.refreshTimer = setInterval(this.refreshScenes, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.refreshTimer) {
|
||||||
|
clearInterval(this.refreshTimer);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshScenes() {
|
||||||
|
if (this.state.loading) return;
|
||||||
|
try {
|
||||||
|
this.setState({ loading: true, error: null });
|
||||||
|
const raw = await this.model.getMixerScenes();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const sceneList: MixerSceneInfo[] = (parsed.scenes || []).map((s: any) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name || `Scene ${s.id}`,
|
||||||
|
}));
|
||||||
|
// Sort by ID for consistent display
|
||||||
|
sceneList.sort((a, b) => a.id - b.id);
|
||||||
|
this.setState({ scenes: sceneList, loading: false });
|
||||||
|
} catch (err: any) {
|
||||||
|
this.setState({ loading: false, error: err?.message || 'Failed to load scenes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get scene for a visual slot (0..7). Slots wrap around the first 8 scenes. */
|
||||||
|
getSlotScene(slot: number): MixerSceneInfo | null {
|
||||||
|
return this.state.scenes[slot] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSaveClick() {
|
||||||
|
this.setState({
|
||||||
|
saveDialogOpen: true,
|
||||||
|
saveName: `Scene ${this.state.scenes.length + 1}`,
|
||||||
|
saving: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSaveDialogClose() {
|
||||||
|
this.setState({ saveDialogOpen: false, saveName: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSaveConfirm() {
|
||||||
|
const name = this.state.saveName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
this.setState({ saving: true });
|
||||||
|
try {
|
||||||
|
await this.model.saveMixerScene(name);
|
||||||
|
this.setState({ saveDialogOpen: false, saveName: '', saving: false });
|
||||||
|
await this.refreshScenes();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.setState({
|
||||||
|
saving: false,
|
||||||
|
error: err?.message || 'Failed to save scene',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSlotClick(slot: number) {
|
||||||
|
const scene = this.getSlotScene(slot);
|
||||||
|
if (!scene) {
|
||||||
|
// Empty slot — save dialog
|
||||||
|
this.setState({
|
||||||
|
saveDialogOpen: true,
|
||||||
|
saveName: `Scene ${slot + 1}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Confirm load
|
||||||
|
this.setState({
|
||||||
|
confirmLoadId: scene.id,
|
||||||
|
confirmLoadName: scene.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLoadConfirm() {
|
||||||
|
const id = this.state.confirmLoadId;
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.model.loadMixerScene(id);
|
||||||
|
this.setState({ confirmLoadId: null, confirmLoadName: '' });
|
||||||
|
} catch (err: any) {
|
||||||
|
this.setState({
|
||||||
|
confirmLoadId: null,
|
||||||
|
confirmLoadName: '',
|
||||||
|
error: err?.message || 'Failed to load scene',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoadCancel() {
|
||||||
|
this.setState({ confirmLoadId: null, confirmLoadName: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDeleteClick(slot: number, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const scene = this.getSlotScene(slot);
|
||||||
|
if (!scene) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use mixerLoadScene with negative ID as a delete signal,
|
||||||
|
// or just skip delete since there's no backend handler for it.
|
||||||
|
// For now, we'll hide slots by calling delete with a workaround.
|
||||||
|
// Actually, let's check if mixerDeleteScene exists...
|
||||||
|
// Since it doesn't, we'll just call the backend to overwrite the scene.
|
||||||
|
this.setState({ error: 'Scene deletion: use Save to overwrite slots.' });
|
||||||
|
setTimeout(() => this.setState({ error: null }), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.setState({ error: err?.message || 'Failed to delete scene' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSlot(slot: number) {
|
||||||
|
const scene = this.getSlotScene(slot);
|
||||||
|
const isUsed = scene !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slot}
|
||||||
|
onClick={() => this.handleSlotClick(slot)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 80,
|
||||||
|
height: 64,
|
||||||
|
margin: 2,
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: isUsed
|
||||||
|
? 'rgba(167, 112, 228, 0.2)' // primary color tint
|
||||||
|
: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
border: isUsed
|
||||||
|
? '1px solid rgba(167, 112, 228, 0.5)'
|
||||||
|
: '1px dashed rgba(255, 255, 255, 0.15)',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
position: 'relative',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = isUsed
|
||||||
|
? 'rgba(167, 112, 228, 0.35)'
|
||||||
|
: 'rgba(255, 255, 255, 0.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = isUsed
|
||||||
|
? 'rgba(167, 112, 228, 0.2)'
|
||||||
|
: 'rgba(255, 255, 255, 0.05)';
|
||||||
|
}}
|
||||||
|
title={isUsed ? `Load: ${scene!.name}` : 'Save current state here'}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
opacity: 0.5,
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
left: 6,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slot + 1}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isUsed ? (
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
textAlign: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: 68,
|
||||||
|
color: '#d0b0f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scene!.name}
|
||||||
|
</Typography>
|
||||||
|
<div style={{ display: 'flex', gap: 2, marginTop: 2 }}>
|
||||||
|
<Tooltip title="Restore">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
style={{ padding: 2, opacity: 0.7 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleSlotClick(slot);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RestoreIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Overwrite">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
style={{ padding: 2, opacity: 0.7 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.setState({
|
||||||
|
saveDialogOpen: true,
|
||||||
|
saveName: scene!.name,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SaveIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
opacity: 0.35,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Empty
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { loading, error, saveDialogOpen, saveName, saving, confirmLoadId, confirmLoadName } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
padding: this.props.compact ? 0 : 8,
|
||||||
|
width: '100%',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '2px 4px',
|
||||||
|
}}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
opacity: 0.8,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scenes
|
||||||
|
</Typography>
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
{loading && (
|
||||||
|
<Typography variant="caption" style={{ opacity: 0.5 }}>
|
||||||
|
...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Tooltip title="Save current mixer state as new scene">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
onClick={this.handleSaveClick}
|
||||||
|
style={{
|
||||||
|
minHeight: 28,
|
||||||
|
fontSize: 11,
|
||||||
|
textTransform: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
padding: '2px 8px',
|
||||||
|
background: 'rgba(255, 96, 96, 0.15)',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}>
|
||||||
|
<WarningAmberIcon sx={{ fontSize: 14, color: '#FF6060' }} />
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
style={{ color: '#FF6060', fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scene slots grid */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 2,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
}}>
|
||||||
|
{Array.from({ length: MAX_SCENES }, (_, i) => this.renderSlot(i))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={saveDialogOpen}
|
||||||
|
onClose={this.handleSaveDialogClose}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Save Mixer Scene</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Scene Name"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={saveName}
|
||||||
|
onChange={(e) => this.setState({ saveName: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') this.handleSaveConfirm();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={this.handleSaveDialogClose} color="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={this.handleSaveConfirm}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!saveName.trim() || saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Load Confirm Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={confirmLoadId !== null}
|
||||||
|
onClose={this.handleLoadCancel}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Load Scene</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Restore scene "{confirmLoadName}"?
|
||||||
|
This will overwrite the current mixer settings.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={this.handleLoadCancel} color="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={this.handleLoadConfirm} variant="contained" color="primary">
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3986,6 +3986,39 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Mixer Scene Management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full mixer state as a JSON string.
|
||||||
|
*/
|
||||||
|
async getMixerState(): Promise<string> {
|
||||||
|
return await this.getWebSocket().request<string>("mixerGetState");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the current mixer state as a named scene.
|
||||||
|
* Returns {id, name} from the backend.
|
||||||
|
*/
|
||||||
|
async saveMixerScene(name: string): Promise<{ id: number; name: string }> {
|
||||||
|
return await this.getWebSocket().request<{ id: number; name: string }>("mixerSaveScene", { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a scene by its backend ID. Returns true on success.
|
||||||
|
*/
|
||||||
|
async loadMixerScene(sceneId: number): Promise<boolean> {
|
||||||
|
return await this.getWebSocket().request<boolean>("mixerLoadScene", { sceneId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of saved scenes from backend. Returns raw JSON string.
|
||||||
|
*/
|
||||||
|
async getMixerScenes(): Promise<string> {
|
||||||
|
return await this.getWebSocket().request<string>("mixerGetScenes");
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let instance: PiPedalModel | undefined = undefined;
|
let instance: PiPedalModel | undefined = undefined;
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// ChannelStrip — per-input channel control for the mixer console
|
||||||
|
//
|
||||||
|
// Volume fader, pan, mute/solo buttons, channel label, and type badge.
|
||||||
|
// Matches the op-pedal mixer engine's channel strip parameters.
|
||||||
|
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Slider from "@mui/material/Slider";
|
||||||
|
import ToggleButton from "@mui/material/ToggleButton";
|
||||||
|
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import VolumeUp from "@mui/icons-material/VolumeUp";
|
||||||
|
import VolumeOff from "@mui/icons-material/VolumeOff";
|
||||||
|
import MusicNote from "@mui/icons-material/MusicNote";
|
||||||
|
|
||||||
|
import type { MixerChannelState } from "./useMixerWS";
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ChannelStripProps {
|
||||||
|
channel: MixerChannelState;
|
||||||
|
/** Called when a parameter changes — the parent issues the WS command. */
|
||||||
|
onVolumeChange: (channelIndex: number, volumeDb: number) => void;
|
||||||
|
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 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const StripRoot = styled(Box)(({ theme }) => ({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
minWidth: 72,
|
||||||
|
width: 72,
|
||||||
|
padding: "8px 4px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: theme.palette.mode === "dark"
|
||||||
|
? "rgba(255,255,255,0.05)"
|
||||||
|
: "rgba(0,0,0,0.04)",
|
||||||
|
border: `1px solid ${
|
||||||
|
theme.palette.mode === "dark"
|
||||||
|
? "rgba(255,255,255,0.1)"
|
||||||
|
: "rgba(0,0,0,0.08)"
|
||||||
|
}`,
|
||||||
|
userSelect: "none",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const LabelBox = styled(Box)({
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FaderBox = styled(Box)({
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: 120,
|
||||||
|
width: "100%",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ValueLabel = styled(Typography)({
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
opacity: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function dbToPercent(db: number): number {
|
||||||
|
// Map -inf..+12 dB to 0..100%
|
||||||
|
if (db <= -60) return 0;
|
||||||
|
if (db >= 12) return 100;
|
||||||
|
return ((db + 60) / 72) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentToDb(pct: number): number {
|
||||||
|
return (pct / 100) * 72 - 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDb(db: number): string {
|
||||||
|
if (db <= -60) return "-∞";
|
||||||
|
return `${db.toFixed(1)} dB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ChannelStrip: React.FC<ChannelStripProps> = ({
|
||||||
|
channel,
|
||||||
|
onVolumeChange,
|
||||||
|
onPanChange,
|
||||||
|
onMuteToggle,
|
||||||
|
onSoloToggle,
|
||||||
|
onLearnTarget,
|
||||||
|
learnModeActive,
|
||||||
|
}) => {
|
||||||
|
const volPct = dbToPercent(channel.volume);
|
||||||
|
|
||||||
|
const handleVol = useCallback(
|
||||||
|
(_: Event, value: number | number[]) => {
|
||||||
|
const db = percentToDb(value as number);
|
||||||
|
onVolumeChange(channel.channelIndex, db);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelVolume", channel.channelIndex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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, learnModeActive, onLearnTarget]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMute = useCallback(() => {
|
||||||
|
onMuteToggle(channel.channelIndex, !channel.mute);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelMute", channel.channelIndex);
|
||||||
|
}
|
||||||
|
}, [channel.channelIndex, channel.mute, onMuteToggle, learnModeActive, onLearnTarget]);
|
||||||
|
|
||||||
|
const handleSolo = useCallback(() => {
|
||||||
|
onSoloToggle(channel.channelIndex, !channel.solo);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelSolo", channel.channelIndex);
|
||||||
|
}
|
||||||
|
}, [channel.channelIndex, channel.solo, onSoloToggle, learnModeActive, onLearnTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StripRoot>
|
||||||
|
{/* Channel label */}
|
||||||
|
<LabelBox>
|
||||||
|
<Typography variant="caption" noWrap sx={{ fontWeight: 600 }}>
|
||||||
|
{channel.label || `Ch ${channel.channelIndex + 1}`}
|
||||||
|
</Typography>
|
||||||
|
</LabelBox>
|
||||||
|
|
||||||
|
{/* Type badge */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
opacity: 0.5,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channel.type}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Volume fader (vertical) */}
|
||||||
|
<FaderBox>
|
||||||
|
<ValueLabel variant="caption">{formatDb(channel.volume)}</ValueLabel>
|
||||||
|
<Slider
|
||||||
|
value={volPct}
|
||||||
|
onChange={handleVol}
|
||||||
|
orientation="vertical"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.5}
|
||||||
|
sx={{ height: 100, flex: "0 0 auto" }}
|
||||||
|
/>
|
||||||
|
</FaderBox>
|
||||||
|
|
||||||
|
{/* Pan slider */}
|
||||||
|
<Box sx={{ width: "100%", px: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: "0.55rem", opacity: 0.5 }}>
|
||||||
|
Pan
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={channel.pan}
|
||||||
|
onChange={handlePan}
|
||||||
|
min={-1}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
size="small"
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={(v: number) => v === 0 ? "C" : v < 0 ? `L${Math.round(-v * 100)}` : `R${Math.round(v * 100)}`}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mute / Solo buttons */}
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
value={[]}
|
||||||
|
sx={{ gap: 0.5, "& .MuiToggleButton-root": { px: 1, py: 0, minWidth: 32, height: 28, fontSize: "0.7rem" } }}
|
||||||
|
>
|
||||||
|
<ToggleButton
|
||||||
|
value="mute"
|
||||||
|
selected={channel.mute}
|
||||||
|
onChange={handleMute}
|
||||||
|
color={channel.mute ? "error" : "standard"}
|
||||||
|
>
|
||||||
|
{channel.mute ? <VolumeOff fontSize="inherit" /> : <VolumeUp fontSize="inherit" />}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton
|
||||||
|
value="solo"
|
||||||
|
selected={channel.solo}
|
||||||
|
onChange={handleSolo}
|
||||||
|
color={channel.solo ? "warning" : "standard"}
|
||||||
|
>
|
||||||
|
<MusicNote fontSize="inherit" />
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</StripRoot>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChannelStrip;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// MasterBus — master output bus strip for the mixer console
|
||||||
|
//
|
||||||
|
// Volume fader, mute, bus name. Styled to visually differentiate
|
||||||
|
// from channel strips (wider, accent colour).
|
||||||
|
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Slider from "@mui/material/Slider";
|
||||||
|
import ToggleButton from "@mui/material/ToggleButton";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import VolumeUp from "@mui/icons-material/VolumeUp";
|
||||||
|
import VolumeOff from "@mui/icons-material/VolumeOff";
|
||||||
|
|
||||||
|
import type { MixerBusState } from "./useMixerWS";
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BusRoot = styled(Box)(({ theme }) => ({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
minWidth: 80,
|
||||||
|
width: 80,
|
||||||
|
padding: "8px 4px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background:
|
||||||
|
theme.palette.mode === "dark"
|
||||||
|
? "rgba(167,112,228,0.12)"
|
||||||
|
: "rgba(103,80,164,0.08)",
|
||||||
|
border: `1px solid ${
|
||||||
|
theme.palette.mode === "dark"
|
||||||
|
? "rgba(167,112,228,0.3)"
|
||||||
|
: "rgba(103,80,164,0.25)"
|
||||||
|
}`,
|
||||||
|
userSelect: "none",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const FaderBox = styled(Box)({
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: 120,
|
||||||
|
width: "100%",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ValueLabel = styled(Typography)({
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
opacity: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function dbToPercent(db: number): number {
|
||||||
|
if (db <= -60) return 0;
|
||||||
|
if (db >= 12) return 100;
|
||||||
|
return ((db + 60) / 72) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDb(db: number): string {
|
||||||
|
if (db <= -60) return "-∞";
|
||||||
|
return `${db.toFixed(1)} dB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MasterBus: React.FC<MasterBusProps> = ({
|
||||||
|
bus,
|
||||||
|
onVolumeChange,
|
||||||
|
onMuteToggle,
|
||||||
|
onLearnTarget,
|
||||||
|
learnModeActive,
|
||||||
|
}) => {
|
||||||
|
const volPct = dbToPercent(bus.volume);
|
||||||
|
|
||||||
|
const handleVol = useCallback(
|
||||||
|
(_: 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, bus.type, onVolumeChange, learnModeActive, onLearnTarget]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMute = useCallback(() => {
|
||||||
|
onMuteToggle(bus.id, !bus.mute);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
const targetType = bus.type === "Master" ? "masterMute" : "busMute";
|
||||||
|
onLearnTarget(targetType, bus.id);
|
||||||
|
}
|
||||||
|
}, [bus.id, bus.mute, bus.type, onMuteToggle, learnModeActive, onLearnTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BusRoot>
|
||||||
|
{/* Bus name */}
|
||||||
|
<Typography variant="caption" noWrap sx={{ fontWeight: 700 }}>
|
||||||
|
{bus.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
opacity: 0.5,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bus.type}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Volume fader */}
|
||||||
|
<FaderBox>
|
||||||
|
<ValueLabel variant="caption">{formatDb(bus.volume)}</ValueLabel>
|
||||||
|
<Slider
|
||||||
|
value={volPct}
|
||||||
|
onChange={handleVol}
|
||||||
|
orientation="vertical"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.5}
|
||||||
|
sx={{ height: 100, flex: "0 0 auto" }}
|
||||||
|
/>
|
||||||
|
</FaderBox>
|
||||||
|
|
||||||
|
{/* Mute button */}
|
||||||
|
<ToggleButton
|
||||||
|
value="mute"
|
||||||
|
selected={bus.mute}
|
||||||
|
onChange={handleMute}
|
||||||
|
size="small"
|
||||||
|
color={bus.mute ? "error" : "standard"}
|
||||||
|
sx={{ px: 1, py: 0, minWidth: 48, height: 28, fontSize: "0.7rem" }}
|
||||||
|
>
|
||||||
|
{bus.mute ? (
|
||||||
|
<VolumeOff fontSize="inherit" />
|
||||||
|
) : (
|
||||||
|
<VolumeUp fontSize="inherit" />
|
||||||
|
)}
|
||||||
|
</ToggleButton>
|
||||||
|
</BusRoot>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MasterBus;
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
// MixerPage — main mixer console view
|
||||||
|
//
|
||||||
|
// Connects to pipedald via useMixerWS, fetches full mixer state,
|
||||||
|
// renders channel strips and master bus, and sends parameter
|
||||||
|
// changes back to the engine.
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
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, 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 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MixerPageProps {
|
||||||
|
/** Called when the user closes the mixer view. */
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styled ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Toolbar = styled(Box)({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "4px 8px",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ScrollContainer = styled(Box)({
|
||||||
|
flex: 1,
|
||||||
|
overflowX: "auto",
|
||||||
|
overflowY: "hidden",
|
||||||
|
padding: "8px 12px",
|
||||||
|
});
|
||||||
|
|
||||||
|
const StripRow = styled(Box)({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 8,
|
||||||
|
height: "100%",
|
||||||
|
minWidth: "min-content",
|
||||||
|
});
|
||||||
|
|
||||||
|
const StickyDivider = styled(Box)(({ theme }) => ({
|
||||||
|
width: 2,
|
||||||
|
alignSelf: "stretch",
|
||||||
|
background:
|
||||||
|
theme.palette.mode === "dark"
|
||||||
|
? "rgba(255,255,255,0.15)"
|
||||||
|
: "rgba(0,0,0,0.1)",
|
||||||
|
margin: "0 8px",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Hook: state polling ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function useMixerState(ws: ReturnType<typeof useMixerWS>) {
|
||||||
|
const [state, setState] = useState<MixerState | null>(null);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchState = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await ws.send("mixerGetState");
|
||||||
|
setState(result as MixerState);
|
||||||
|
setFetchError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setFetchError(msg);
|
||||||
|
}
|
||||||
|
}, [ws]);
|
||||||
|
|
||||||
|
// Fetch state when WS connects, and every 2 seconds while connected.
|
||||||
|
useEffect(() => {
|
||||||
|
if (ws.status !== "connected") {
|
||||||
|
setState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchState();
|
||||||
|
const interval = setInterval(fetchState, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [ws.status, fetchState]);
|
||||||
|
|
||||||
|
return { state, fetchError, fetchState };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MixerPage ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MixerPage: React.FC<MixerPageProps> = ({ 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(
|
||||||
|
(channelIndex: number, volume: number) => {
|
||||||
|
ws.send("mixerSetChannelVolume", { channelIndex, volume }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChannelPan = useCallback(
|
||||||
|
(channelIndex: number, pan: number) => {
|
||||||
|
ws.send("mixerSetChannelPan", { channelIndex, pan }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChannelMute = useCallback(
|
||||||
|
(channelIndex: number, mute: boolean) => {
|
||||||
|
ws.send("mixerSetChannelMute", { channelIndex, mute }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChannelSolo = useCallback(
|
||||||
|
(channelIndex: number, solo: boolean) => {
|
||||||
|
ws.send("mixerSetChannelSolo", { channelIndex, solo }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBusVolume = useCallback(
|
||||||
|
(busId: number, volume: number) => {
|
||||||
|
ws.send("mixerSetBusVolume", { busId, volume }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBusMute = useCallback(
|
||||||
|
(busId: number, mute: boolean) => {
|
||||||
|
ws.send("mixerSetBusMute", { busId, mute }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const channels: MixerChannelState[] = state?.channels ?? [];
|
||||||
|
const masterBus: MixerBusState | undefined = state?.buses.find(
|
||||||
|
(b) => b.type === "Master"
|
||||||
|
);
|
||||||
|
const otherBuses = state?.buses.filter((b) => b.type !== "Master") ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: (t) => t.palette.background.default,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton onClick={onClose} size="small">
|
||||||
|
<ArrowBack />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||||
|
Mixer Console
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={fetchState}
|
||||||
|
disabled={ws.status !== "connected"}
|
||||||
|
>
|
||||||
|
<Refresh />
|
||||||
|
</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
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
opacity: 0.6,
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ws.status === "connected"
|
||||||
|
? "● Connected"
|
||||||
|
: ws.status === "connecting"
|
||||||
|
? "◌ Connecting..."
|
||||||
|
: "○ Disconnected"}
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{/* Scene panel */}
|
||||||
|
<Box sx={{ px: 2, py: 0.5, flex: "0 0 auto" }}>
|
||||||
|
<MixerScenePanel ws={ws} disabled={ws.status !== "connected"} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{fetchError && (
|
||||||
|
<Alert severity="warning" sx={{ mx: 1, my: 0.5, py: 0 }}>
|
||||||
|
{fetchError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ws.lastError && (
|
||||||
|
<Alert severity="info" sx={{ mx: 1, my: 0.5, py: 0 }}>
|
||||||
|
{ws.lastError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Strip area */}
|
||||||
|
<ScrollContainer>
|
||||||
|
{ws.status === "connecting" && !state && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
<Typography variant="body2">Connecting to mixer...</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ws.status === "connected" && state && (
|
||||||
|
<StripRow>
|
||||||
|
{/* Channel strips */}
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<ChannelStrip
|
||||||
|
key={ch.channelIndex}
|
||||||
|
channel={ch}
|
||||||
|
onVolumeChange={handleChannelVolume}
|
||||||
|
onPanChange={handleChannelPan}
|
||||||
|
onMuteToggle={handleChannelMute}
|
||||||
|
onSoloToggle={handleChannelSolo}
|
||||||
|
onLearnTarget={handleLearnTarget}
|
||||||
|
learnModeActive={learnModeActive}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
{channels.length > 0 && <StickyDivider />}
|
||||||
|
|
||||||
|
{/* Other buses (subgroups, aux, etc.) */}
|
||||||
|
{otherBuses.map((bus) => (
|
||||||
|
<MasterBus
|
||||||
|
key={bus.id}
|
||||||
|
bus={bus}
|
||||||
|
onVolumeChange={handleBusVolume}
|
||||||
|
onMuteToggle={handleBusMute}
|
||||||
|
onLearnTarget={handleLearnTarget}
|
||||||
|
learnModeActive={learnModeActive}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Master bus */}
|
||||||
|
{masterBus && (
|
||||||
|
<>
|
||||||
|
<StickyDivider />
|
||||||
|
<MasterBus
|
||||||
|
key={masterBus.id}
|
||||||
|
bus={masterBus}
|
||||||
|
onVolumeChange={handleBusVolume}
|
||||||
|
onMuteToggle={handleBusMute}
|
||||||
|
onLearnTarget={handleLearnTarget}
|
||||||
|
learnModeActive={learnModeActive}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{channels.length === 0 && otherBuses.length === 0 && !masterBus && (
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.5, p: 4 }}>
|
||||||
|
No mixer state received yet.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</StripRow>
|
||||||
|
)}
|
||||||
|
</ScrollContainer>
|
||||||
|
|
||||||
|
{/* Output routing panel overlay */}
|
||||||
|
{showOutputRouting && (
|
||||||
|
<OutputRoutingPanel
|
||||||
|
ws={ws}
|
||||||
|
buses={state?.buses ?? []}
|
||||||
|
routes={state?.outputRoutes ?? []}
|
||||||
|
physicalOutputCount={32}
|
||||||
|
onRoutesChanged={fetchState}
|
||||||
|
onClose={() => setShowOutputRouting(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MixerPage;
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
// MixerScenePanel — Scene management for the digital mixer.
|
||||||
|
//
|
||||||
|
// Shows 8 numbered scene slots (like a hardware digital mixer).
|
||||||
|
// Uses the mixer WebSocket to save/load/list scenes via the backend
|
||||||
|
// mixerSaveScene / mixerLoadScene / mixerGetScenes commands.
|
||||||
|
//
|
||||||
|
// Embedded in the MixerPage toolbar area.
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
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 Save from "@mui/icons-material/Save";
|
||||||
|
import Refresh from "@mui/icons-material/Refresh";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import type { MixerWsHandle } from "./useMixerWS";
|
||||||
|
|
||||||
|
export const MAX_SCENES = 8;
|
||||||
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
export interface MixerSceneInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixerScenePanelProps {
|
||||||
|
ws: MixerWsHandle;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MixerScenePanel({ ws, disabled }: MixerScenePanelProps) {
|
||||||
|
const [scenes, setScenes] = useState<MixerSceneInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Save dialog
|
||||||
|
const [saveOpen, setSaveOpen] = useState(false);
|
||||||
|
const [saveName, setSaveName] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Load confirm dialog
|
||||||
|
const [loadConfirm, setLoadConfirm] = useState<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// ── Fetch scene list ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchScenes = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected" || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const raw = (await ws.send("mixerGetScenes")) as string;
|
||||||
|
const parsed = JSON.parse(raw) as { scenes: { id: number; name: string }[] };
|
||||||
|
const list: MixerSceneInfo[] = (parsed.scenes ?? []).map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name || `Scene ${s.id}`,
|
||||||
|
}));
|
||||||
|
list.sort((a, b) => a.id - b.id);
|
||||||
|
setScenes(list);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [ws, loading]);
|
||||||
|
|
||||||
|
// Initial fetch + polling
|
||||||
|
useEffect(() => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
fetchScenes();
|
||||||
|
const interval = setInterval(fetchScenes, POLL_INTERVAL_MS);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [ws.status, fetchScenes]);
|
||||||
|
|
||||||
|
// Fetch when reconnected
|
||||||
|
useEffect(() => {
|
||||||
|
if (ws.status === "connected") {
|
||||||
|
fetchScenes();
|
||||||
|
}
|
||||||
|
}, [ws.status]);
|
||||||
|
|
||||||
|
// ── Slot helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const getSlotScene = useCallback(
|
||||||
|
(slot: number): MixerSceneInfo | null => scenes[slot] ?? null,
|
||||||
|
[scenes],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Save ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleSaveClick = useCallback(() => {
|
||||||
|
setSaveName(`Scene ${scenes.length + 1}`);
|
||||||
|
setSaveOpen(true);
|
||||||
|
}, [scenes.length]);
|
||||||
|
|
||||||
|
const handleSaveConfirm = useCallback(async () => {
|
||||||
|
const name = saveName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await ws.send("mixerSaveScene", { name });
|
||||||
|
setSaveOpen(false);
|
||||||
|
setSaveName("");
|
||||||
|
await fetchScenes();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [saveName, ws, fetchScenes]);
|
||||||
|
|
||||||
|
// ── Load ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleSlotClick = useCallback(
|
||||||
|
(slot: number) => {
|
||||||
|
const scene = getSlotScene(slot);
|
||||||
|
if (!scene) {
|
||||||
|
// Empty slot → save
|
||||||
|
setSaveName(`Scene ${slot + 1}`);
|
||||||
|
setSaveOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadConfirm({ id: scene.id, name: scene.name });
|
||||||
|
},
|
||||||
|
[getSlotScene],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLoadConfirm = useCallback(async () => {
|
||||||
|
if (!loadConfirm) return;
|
||||||
|
try {
|
||||||
|
await ws.send("mixerLoadScene", { sceneId: loadConfirm.id });
|
||||||
|
setLoadConfirm(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(msg);
|
||||||
|
setLoadConfirm(null);
|
||||||
|
}
|
||||||
|
}, [loadConfirm, ws]);
|
||||||
|
|
||||||
|
// ── Render slot ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const renderSlot = useCallback(
|
||||||
|
(slot: number) => {
|
||||||
|
const scene = getSlotScene(slot);
|
||||||
|
const isUsed = scene !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={slot}
|
||||||
|
title={
|
||||||
|
isUsed
|
||||||
|
? `Restore "${scene!.name}"`
|
||||||
|
: `Save current state to slot ${slot + 1}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={() => handleSlotClick(slot)}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 72,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
cursor: disabled ? "default" : "pointer",
|
||||||
|
bgcolor: isUsed ? "rgba(167, 112, 228, 0.18)" : "rgba(255,255,255,0.04)",
|
||||||
|
border: isUsed
|
||||||
|
? "1px solid rgba(167, 112, 228, 0.4)"
|
||||||
|
: "1px dashed rgba(255,255,255,0.12)",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
position: "relative",
|
||||||
|
userSelect: "none",
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
"&:hover": disabled
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
bgcolor: isUsed
|
||||||
|
? "rgba(167, 112, 228, 0.32)"
|
||||||
|
: "rgba(255,255,255,0.09)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Slot number */}
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 1,
|
||||||
|
left: 5,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
opacity: 0.35,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slot + 1}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isUsed ? (
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
textAlign: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 62,
|
||||||
|
color: "#d0b0f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scene!.name}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
sx={{ fontSize: 10, opacity: 0.3, textAlign: "center" }}
|
||||||
|
>
|
||||||
|
Empty
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[getSlotScene, handleSlotClick, disabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Main render ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||||
|
{/* Header row */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
px: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 11,
|
||||||
|
opacity: 0.6,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scenes
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, alignItems: "center" }}>
|
||||||
|
<Tooltip title="Refresh scene list">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={fetchScenes}
|
||||||
|
disabled={ws.status !== "connected" || disabled}
|
||||||
|
sx={{ p: 0.5 }}
|
||||||
|
>
|
||||||
|
<Refresh sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Save current mixer state as a new scene">
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Save sx={{ fontSize: 14 }} />}
|
||||||
|
onClick={handleSaveClick}
|
||||||
|
disabled={ws.status !== "connected" || disabled}
|
||||||
|
sx={{
|
||||||
|
minHeight: 24,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: 10,
|
||||||
|
textTransform: "none",
|
||||||
|
py: 0,
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="warning" sx={{ py: 0, px: 1, fontSize: 11 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scene slot grid */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: MAX_SCENES }, (_, i) => renderSlot(i))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Save dialog ─────────────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
open={saveOpen}
|
||||||
|
onClose={() => setSaveOpen(false)}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Save Mixer Scene</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Scene Name"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={saveName}
|
||||||
|
onChange={(e) => setSaveName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSaveConfirm();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setSaveOpen(false)} color="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveConfirm}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!saveName.trim() || saving}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Load confirm dialog ─────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
open={loadConfirm !== null}
|
||||||
|
onClose={() => setLoadConfirm(null)}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Load Scene</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Restore scene{" "}
|
||||||
|
<strong>{loadConfirm?.name}</strong>?
|
||||||
|
<br />
|
||||||
|
This will overwrite current mixer settings.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setLoadConfirm(null)} color="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleLoadConfirm} variant="contained" color="primary">
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// useMixerWS — WebSocket hook for op-pedal mixer engine protocol
|
||||||
|
//
|
||||||
|
// Connects to pipedald's WebSocket endpoint and provides the mixer
|
||||||
|
// command set (mixerGetState, mixerSetChannelVolume, etc.) matching
|
||||||
|
// the PiPedalSocket message format used in PiPedalSocket.cpp handlers.
|
||||||
|
//
|
||||||
|
// Protocol:
|
||||||
|
// Send: [{"message": "mixerGetState", "replyTo": N}]
|
||||||
|
// Reply: [{"reply": N, "message": "mixerState"}, {…body…}]
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MixerChannelState {
|
||||||
|
channelIndex: number;
|
||||||
|
volume: number; // dB
|
||||||
|
pan: number; // -1..1
|
||||||
|
mute: boolean;
|
||||||
|
solo: boolean;
|
||||||
|
type: string; // "Instrument" | "Mic" | "Line"
|
||||||
|
label: string;
|
||||||
|
hpEnabled: boolean;
|
||||||
|
hpFrequency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixerBusState {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string; // "Master" | "Subgroup" | "Aux" | "FxReturn"
|
||||||
|
volume: number; // dB
|
||||||
|
mute: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixerRouteState {
|
||||||
|
sourceId: number;
|
||||||
|
targetBusId: number;
|
||||||
|
level: number; // dB
|
||||||
|
sourceType: string; // "channel" | "bus"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixerState {
|
||||||
|
channels: MixerChannelState[];
|
||||||
|
buses: MixerBusState[];
|
||||||
|
routes: MixerRouteState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WsStatus =
|
||||||
|
| "disconnected"
|
||||||
|
| "connecting"
|
||||||
|
| "connected"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface MixerWsHandle {
|
||||||
|
/** Current connection status. */
|
||||||
|
status: WsStatus;
|
||||||
|
/** Last error message (cleared on reconnect). */
|
||||||
|
lastError: string | null;
|
||||||
|
/** Send a mixer command and await the reply. */
|
||||||
|
send: (command: string, params?: unknown) => Promise<unknown>;
|
||||||
|
/** Manually connect (auto-called on mount; safe to call again after disconnect). */
|
||||||
|
connect: () => void;
|
||||||
|
/** Disconnect manually. */
|
||||||
|
disconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Defaults ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_WS_URL = "ws://192.168.0.245:8080/ws";
|
||||||
|
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||||
|
const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
||||||
|
|
||||||
|
// ── Hook ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useMixerWS(wsUrl: string = DEFAULT_WS_URL): MixerWsHandle {
|
||||||
|
const [status, setStatus] = useState<WsStatus>("disconnected");
|
||||||
|
const [lastError, setLastError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const replyMapRef = useRef<Map<number, (value: unknown) => void>>(new Map());
|
||||||
|
const nextReplyToRef = useRef(0);
|
||||||
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reconnectAttemptRef = useRef(0);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const wsUrlRef = useRef(wsUrl);
|
||||||
|
|
||||||
|
// Keep the URL ref current without triggering re-renders.
|
||||||
|
useEffect(() => {
|
||||||
|
wsUrlRef.current = wsUrl;
|
||||||
|
}, [wsUrl]);
|
||||||
|
|
||||||
|
/** Clean up WS and timers. */
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
// Clear reconnect timer.
|
||||||
|
if (reconnectTimerRef.current !== null) {
|
||||||
|
clearTimeout(reconnectTimerRef.current);
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close socket cleanly.
|
||||||
|
const sock = wsRef.current;
|
||||||
|
if (sock) {
|
||||||
|
sock.onopen = null;
|
||||||
|
sock.onclose = null;
|
||||||
|
sock.onerror = null;
|
||||||
|
sock.onmessage = null;
|
||||||
|
if (sock.readyState === WebSocket.OPEN || sock.readyState === WebSocket.CONNECTING) {
|
||||||
|
sock.close();
|
||||||
|
}
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject all pending promises.
|
||||||
|
const pending = replyMapRef.current;
|
||||||
|
if (pending.size > 0) {
|
||||||
|
const err = new Error("WebSocket disconnected");
|
||||||
|
for (const reject of pending.values()) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Open a new WebSocket connection. */
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) {
|
||||||
|
return; // already connected or connecting
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
setStatus("connecting");
|
||||||
|
setLastError(null);
|
||||||
|
|
||||||
|
const url = wsUrlRef.current;
|
||||||
|
let sock: WebSocket;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sock = new WebSocket(url);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setStatus("error");
|
||||||
|
setLastError(`Failed to create WebSocket: ${msg}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.onopen = () => {
|
||||||
|
if (!mountedRef.current) { sock.close(); return; }
|
||||||
|
wsRef.current = sock;
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setStatus("connected");
|
||||||
|
setLastError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onclose = () => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (wsRef.current === sock) {
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
setStatus("disconnected");
|
||||||
|
// Schedule reconnect with exponential backoff.
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onerror = () => {
|
||||||
|
// The onclose event will fire after onerror, so we don't set
|
||||||
|
// status here to avoid a flash of "error" followed by "disconnected".
|
||||||
|
setLastError("WebSocket connection error");
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onmessage = (event: MessageEvent) => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON messages.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) return;
|
||||||
|
if (parsed.length < 1) return;
|
||||||
|
|
||||||
|
const header = parsed[0] as Record<string, unknown>;
|
||||||
|
const body = parsed.length >= 2 ? parsed[1] : undefined;
|
||||||
|
|
||||||
|
// If this is a reply to one of our requests, resolve the promise.
|
||||||
|
if (typeof header.reply === "number") {
|
||||||
|
const resolve = replyMapRef.current.get(header.reply);
|
||||||
|
if (resolve) {
|
||||||
|
replyMapRef.current.delete(header.reply);
|
||||||
|
if (header.message === "error") {
|
||||||
|
resolve(Promise.reject(new Error(String(body ?? "Unknown error"))));
|
||||||
|
} else {
|
||||||
|
resolve(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unprompted messages (state updates, etc.) are ignored at this level.
|
||||||
|
// Subscribers can extend the hook later if they need push events.
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current = sock;
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
/** Schedule a reconnection attempt with exponential backoff. */
|
||||||
|
const scheduleReconnect = useCallback(() => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (reconnectTimerRef.current !== null) return; // already scheduled
|
||||||
|
|
||||||
|
const attempt = reconnectAttemptRef.current;
|
||||||
|
const delay = Math.min(
|
||||||
|
INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt),
|
||||||
|
MAX_RECONNECT_DELAY_MS
|
||||||
|
);
|
||||||
|
reconnectAttemptRef.current = attempt + 1;
|
||||||
|
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
/** Send a command and wait for the response. */
|
||||||
|
const send = useCallback((command: string, params?: unknown): Promise<unknown> => {
|
||||||
|
return new Promise<unknown>((resolve, reject) => {
|
||||||
|
const sock = wsRef.current;
|
||||||
|
if (!sock || sock.readyState !== WebSocket.OPEN) {
|
||||||
|
reject(new Error("WebSocket not connected"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyTo = ++nextReplyToRef.current;
|
||||||
|
|
||||||
|
// Build message in PiPedalSocket format: [header, body?]
|
||||||
|
const header: Record<string, unknown> = { message: command, replyTo };
|
||||||
|
const msg = params !== undefined ? [header, params] : [header];
|
||||||
|
|
||||||
|
// Register the reply handler before sending (race-safe).
|
||||||
|
replyMapRef.current.set(replyTo, (value: unknown) => {
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a timeout for the reply.
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
replyMapRef.current.delete(replyTo);
|
||||||
|
reject(new Error(`Command "${command}" timed out after 10s`));
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// Wrap the resolve to clear the timeout.
|
||||||
|
const originalResolve = replyMapRef.current.get(replyTo)!;
|
||||||
|
replyMapRef.current.set(replyTo, (value: unknown) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
originalResolve(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
sock.send(JSON.stringify(msg));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
replyMapRef.current.delete(replyTo);
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
cleanup();
|
||||||
|
setStatus("disconnected");
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
// ── Lifecycle ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
// Connect on mount.
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, [connect, cleanup]);
|
||||||
|
|
||||||
|
return { status, lastError, send, connect, disconnect };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user