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;
|
||||
}
|
||||
|
||||
virtual uint32_t GetDeviceCaptureChannels() const override
|
||||
{
|
||||
return (uint32_t)this->captureChannels;
|
||||
}
|
||||
|
||||
virtual uint32_t GetDevicePlaybackChannels() const override
|
||||
{
|
||||
return (uint32_t)this->playbackChannels;
|
||||
}
|
||||
|
||||
JackServerSettings jackServerSettings;
|
||||
|
||||
std::string alsa_device_name;
|
||||
|
||||
@@ -57,6 +57,12 @@ namespace pipedal {
|
||||
|
||||
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 MidiEvent*GetMidiEvents() = 0;
|
||||
|
||||
|
||||
+81
-6
@@ -24,12 +24,14 @@
|
||||
#include <lv2/atom/atom.h>
|
||||
#include "SchedulerPriority.hpp"
|
||||
#include "AlsaSequencer.hpp"
|
||||
#include "MixerEngine.hpp"
|
||||
|
||||
#include "Lv2Log.hpp"
|
||||
|
||||
#include "SchedulerPriority.hpp"
|
||||
#include "JackDriver.hpp"
|
||||
#include "AlsaDriver.hpp"
|
||||
#include "PipeWireDriver.hpp"
|
||||
#include "DummyAudioDriver.hpp"
|
||||
#include "AtomConverter.hpp"
|
||||
#include <unordered_map>
|
||||
@@ -481,6 +483,7 @@ private:
|
||||
Uris uris;
|
||||
|
||||
std::unique_ptr<AudioDriver> audioDriver;
|
||||
std::string driverType_;
|
||||
|
||||
std::recursive_mutex mutex;
|
||||
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.
|
||||
Lv2Pedalboard *realtimeActivePedalboard = nullptr;
|
||||
|
||||
// Band-in-a-Box Mixer Engine
|
||||
std::shared_ptr<MixerEngine> currentMixerEngine;
|
||||
MixerEngine *realtimeActiveMixerEngine = nullptr;
|
||||
|
||||
uint32_t sampleRate = 0;
|
||||
uint64_t currentSample = 0;
|
||||
|
||||
@@ -1083,6 +1090,11 @@ private:
|
||||
{
|
||||
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
|
||||
{
|
||||
ProcessMidiMonitor(eventBufferWriter, iterator, event);
|
||||
@@ -1197,6 +1209,24 @@ private:
|
||||
|
||||
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;
|
||||
|
||||
std::vector<float *> *pInputBuffers;
|
||||
@@ -1395,7 +1425,7 @@ private:
|
||||
}
|
||||
bool processed = false;
|
||||
|
||||
if (pedalboard != nullptr)
|
||||
if (pedalboard != nullptr || this->realtimeActiveMixerEngine != nullptr)
|
||||
{
|
||||
ProcessGlobalMidiInput();
|
||||
}
|
||||
@@ -1421,7 +1451,7 @@ private:
|
||||
}
|
||||
|
||||
public:
|
||||
AudioHostImpl(IHost *pHost)
|
||||
AudioHostImpl(IHost *pHost, const std::string &driverType)
|
||||
: inputRingBuffer(RING_BUFFER_SIZE),
|
||||
outputRingBuffer(RING_BUFFER_SIZE),
|
||||
realtimeReader(&this->inputRingBuffer),
|
||||
@@ -1431,7 +1461,8 @@ public:
|
||||
eventBufferUrids(pHost),
|
||||
pHost(pHost),
|
||||
uris(pHost),
|
||||
atomConverter(pHost->GetMapFeature())
|
||||
atomConverter(pHost->GetMapFeature()),
|
||||
driverType_(driverType)
|
||||
{
|
||||
realtimeAtomBuffer.resize(32 * 1024);
|
||||
lv2_atom_forge_init(&inputWriterForge, pHost->GetMapFeature().GetMap());
|
||||
@@ -1470,6 +1501,25 @@ public:
|
||||
{
|
||||
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(
|
||||
AlsaSequencerDeviceMonitor::MonitorAction action, int client, const std::string &clientName)
|
||||
{
|
||||
@@ -1858,7 +1908,16 @@ public:
|
||||
isOpen = true;
|
||||
|
||||
this->isDummyAudioDriver = jackServerSettings.IsDummyAudioDevice();
|
||||
this->audioDriver = std::unique_ptr<AudioDriver>(CreateAlsaDriver(this));
|
||||
|
||||
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->currentSample = 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)
|
||||
{
|
||||
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,
|
||||
|
||||
+13
-1
@@ -37,6 +37,8 @@
|
||||
namespace pipedal
|
||||
{
|
||||
|
||||
class MixerEngine; // forward declaration for band-in-a-box mode
|
||||
|
||||
struct RealtimeMidiProgramRequest;
|
||||
struct RealtimeNextMidiProgramRequest;
|
||||
class PluginHost;
|
||||
@@ -213,7 +215,7 @@ namespace pipedal
|
||||
AudioHost() {}
|
||||
|
||||
public:
|
||||
static AudioHost *CreateInstance(IHost *pHost);
|
||||
static AudioHost *CreateInstance(IHost *pHost, const std::string &driverType = "alsa");
|
||||
virtual ~AudioHost() {};
|
||||
|
||||
virtual void UpdateServerConfiguration(const JackServerSettings &jackServerSettings,
|
||||
@@ -235,10 +237,20 @@ namespace pipedal
|
||||
virtual void SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) = 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 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;
|
||||
|
||||
@@ -366,9 +366,18 @@ set (PIPEDAL_SOURCES
|
||||
JackDriver.cpp JackDriver.hpp
|
||||
AlsaDriver.cpp AlsaDriver.hpp
|
||||
DummyAudioDriver.cpp DummyAudioDriver.hpp
|
||||
PipeWireDriver.cpp PipeWireDriver.hpp
|
||||
AudioDriver.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}
|
||||
)
|
||||
|
||||
|
||||
@@ -136,6 +136,16 @@ namespace pipedal
|
||||
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;
|
||||
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 "RegDb.hpp"
|
||||
#include "RingBufferReader.hpp"
|
||||
#include "MixerEngine.hpp"
|
||||
#include "PiPedalUI.hpp"
|
||||
#include "atom_object.hpp"
|
||||
#include "Lv2PluginChangeMonitor.hpp"
|
||||
@@ -438,7 +439,7 @@ void PiPedalModel::Load()
|
||||
|
||||
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->SetNotificationCallbacks(this);
|
||||
@@ -3081,6 +3082,33 @@ std::shared_ptr<Lv2Pedalboard> PiPedalModel::GetLv2Pedalboard()
|
||||
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)
|
||||
{
|
||||
// Thinking on this:
|
||||
|
||||
@@ -210,6 +210,7 @@ namespace pipedal
|
||||
std::unique_ptr<AudioHost> audioHost;
|
||||
JackConfiguration jackConfiguration;
|
||||
std::shared_ptr<Lv2Pedalboard> lv2Pedalboard;
|
||||
std::shared_ptr<MixerEngine> mixerEngine;
|
||||
std::filesystem::path webRoot;
|
||||
|
||||
using SubscriberList = std::vector<std::shared_ptr<IPiPedalModelSubscriber>>;
|
||||
@@ -285,6 +286,7 @@ namespace pipedal
|
||||
void UpdateVst3Settings(Pedalboard &pedalboard);
|
||||
|
||||
PiPedalConfiguration configuration;
|
||||
std::string driverType_ = "alsa";
|
||||
|
||||
void CheckForResourceInitialization(Pedalboard &pedalboard);
|
||||
UiFileProperty::ptr FindLoadedPatchProperty(int64_t instanceId, const std::string &patchPropertyUri);
|
||||
@@ -293,6 +295,9 @@ namespace pipedal
|
||||
PiPedalModel();
|
||||
virtual ~PiPedalModel();
|
||||
|
||||
void SetDriverType(const std::string &driverType) { driverType_ = driverType; }
|
||||
const std::string &GetDriverType() const { return driverType_; }
|
||||
|
||||
enum class Direction
|
||||
{
|
||||
Increase,
|
||||
@@ -401,6 +406,8 @@ namespace pipedal
|
||||
void SetPedalboard(int64_t clientId, Pedalboard &pedalboard);
|
||||
Pedalboard &GetPedalboard();
|
||||
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);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include "Curl.hpp"
|
||||
#include "PiPedalSocket.hpp"
|
||||
#include "MixerApi.hpp"
|
||||
#include "Updater.hpp"
|
||||
#include "json.hpp"
|
||||
#include "viewstream.hpp"
|
||||
@@ -707,6 +708,7 @@ private:
|
||||
|
||||
std::recursive_mutex writeMutex;
|
||||
PiPedalModel &model;
|
||||
MixerApi mixerApi;
|
||||
static std::atomic<uint64_t> nextClientId;
|
||||
std::string imageList;
|
||||
|
||||
@@ -814,6 +816,12 @@ public:
|
||||
PiPedalSocketHandler(PiPedalModel &model)
|
||||
: model(model), clientId(++nextClientId)
|
||||
{
|
||||
// Wire MixerEngine to MixerApi if available
|
||||
auto engine = model.GetMixerEngine();
|
||||
if (engine) {
|
||||
this->mixerApi.setMixerEngine(engine.get());
|
||||
}
|
||||
|
||||
std::stringstream imageList;
|
||||
const std::filesystem::path &webRoot = model.GetWebRoot() / "img";
|
||||
bool firstTime = true;
|
||||
@@ -1217,6 +1225,289 @@ public:
|
||||
}
|
||||
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)
|
||||
{
|
||||
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 <execinfo.h>
|
||||
#include "PiPedalSocket.hpp"
|
||||
#include "MixerEngine.hpp"
|
||||
#include "PluginHost.hpp"
|
||||
#include <boost/system/error_code.hpp>
|
||||
#include <filesystem>
|
||||
@@ -185,6 +186,7 @@ int main(int argc, char *argv[])
|
||||
bool testExtraDevice = false;
|
||||
std::string logLevel;
|
||||
std::string portOption;
|
||||
std::string driverOption = "alsa";
|
||||
|
||||
CommandLineParser parser;
|
||||
parser.AddOption("-h", &help);
|
||||
@@ -193,6 +195,7 @@ int main(int argc, char *argv[])
|
||||
parser.AddOption("-log-level", &logLevel);
|
||||
parser.AddOption("-port", &portOption);
|
||||
parser.AddOption("-test-extra-device", &testExtraDevice); // advertise two different devices (for testing multi-device connect)
|
||||
parser.AddOption("--driver", &driverOption);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -226,6 +229,7 @@ int main(int argc, char *argv[])
|
||||
<< " -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"
|
||||
<< " -log-level: (debug|info|warning|error)\n"
|
||||
<< " --driver: Audio driver (alsa|pipewire, default: alsa)\n"
|
||||
<< "Example:\n"
|
||||
<< " pipedald /etc/pipedal/config /etc/pipedal/react -port 80 \n"
|
||||
"\n"
|
||||
@@ -398,6 +402,7 @@ int main(int argc, char *argv[])
|
||||
});
|
||||
|
||||
model.Init(configuration);
|
||||
model.SetDriverType(driverOption);
|
||||
|
||||
// Get heavy IO out of the way before letting dependent (Jack/ALSA) services run.
|
||||
model.LoadLv2PluginInfo();
|
||||
@@ -447,6 +452,18 @@ int main(int argc, char *argv[])
|
||||
|
||||
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);
|
||||
|
||||
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 FxAmplifierIcon from './svg/fx_amplifier.svg?react';
|
||||
import { PerformanceView } from './PerformanceView';
|
||||
import MusicNoteIcon from '@mui/icons-material/MusicNote';
|
||||
import MixerPage from './mixer/MixerPage';
|
||||
|
||||
|
||||
import DialogEx from './DialogEx';
|
||||
@@ -322,6 +324,7 @@ type AppState = {
|
||||
banks: BankIndex;
|
||||
bankDisplayItems: number;
|
||||
showStatusMonitor: boolean;
|
||||
mixerView: boolean;
|
||||
};
|
||||
class MenuStackHandler implements IDialogStackable {
|
||||
constructor(app: AppThemedBase) {
|
||||
@@ -380,7 +383,8 @@ export
|
||||
editBankDialogOpen: false,
|
||||
zoomedControlOpen: false,
|
||||
bankDisplayItems: 5,
|
||||
showStatusMonitor: this.model_.showStatusMonitor.get()
|
||||
showStatusMonitor: this.model_.showStatusMonitor.get(),
|
||||
mixerView: false,
|
||||
|
||||
};
|
||||
|
||||
@@ -802,6 +806,8 @@ export
|
||||
<PerformanceView open={this.state.performanceView}
|
||||
onClose={() => { this.setState({ performanceView: false }); }}
|
||||
/>
|
||||
) : this.state.mixerView ? (
|
||||
<MixerPage onClose={() => { this.setState({ mixerView: false }); }} />
|
||||
) : (
|
||||
<div style={{
|
||||
position: "absolute", width: "100%", height: "100%", userSelect: "none",
|
||||
@@ -890,6 +896,17 @@ export
|
||||
</ListItemIcon>
|
||||
<ListItemText primary='Performance View' />
|
||||
</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>
|
||||
<Divider />
|
||||
<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;
|
||||
|
||||
@@ -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