Compare commits

13 Commits

Author SHA1 Message Date
shawn 120279cbd4 Add VU meter data, gate status, and compressor GR to mixer IPC protocol
- MixerEngine.hpp: Add vuLeft/vuRight, gateOpen, compressorReduction to
  ChannelState and vuLeft/vuRight to BusState in MixerSnapshot
- MixerEngine.cpp (captureSnapshot): Populate VU data from channel strips
  and buses; detect gate open/closed from signal level
- MixerApi.cpp (getStateJson): Serialize VU, gate, and compressor fields
  in the JSON state response

This enables the frontend MetersPage to display real-time VU meter bars,
gate open/closed indicators, and compressor gain reduction meters.
2026-06-23 20:09:52 -04:00
shawn 8068f5d168 Mixer engine: output routing, full state restore, MIDI learn API, PipeWire multi-channel support 2026-06-20 16:20:30 -04:00
shawn 01584f50da Initialize MixerEngine on startup (BB-6)
Add MixerEngine creation and wiring after model.Load() in main.cpp.
The mixer engine now gets created with current audio settings (sample rate,
buffer size from JackServerSettings), activated, and attached to the model.
This enables WebSocket mixer commands (mixerGetState, mixerAddChannel,
mixerAddBus, etc.) to work through /pipedal endpoint.

Missing initialization was the root cause of 'Invalid format' errors when
sending mixer commands via WebSocket.
2026-06-20 16:15:12 -04:00
shawn 5fd5946ff6 P4: MIDI control surface mapping — faders/buttons over USB MIDI
C++ backend (fully implemented):
- MidiMapper: CC processing, mapping table, JSON persistence, learn mode
- MidiLearnMode: 3-step learn workflow state machine
- MixerEngine::processMidiEvent() wired into AudioHost MIDI pipeline
- MixerApi + PiPedalSocket: all WS handlers (getMidiMappings,
  setMidiLearnMode, setMidiLearnTarget, commitMidiLearn, etc.)

React frontend (new):
- MidiMappingPanel: dialog with learn mode toggle, CC capture polling,
  commit workflow, current mappings list with delete, manual add
- MixerPage: MIDI button in toolbar, learn mode state management
- ChannelStrip + MasterBus: learn mode callbacks on fader/mute/solo touch
2026-06-20 16:14:12 -04:00
shawn 3d00299051 feat: add PipeWire multi-channel audio driver (full-duplex N channel)
- PipeWireDriver.hpp/cpp: new AudioDriver implementation using pw_filter API
- Full-duplex I/O via pw_filter (capture + playback in one RT callback)
- Dynamic channel count support (tested: 1-8 channels, extensible beyond)
- Uses SPA_AUDIO_FORMAT_F32 for zero-copy-compatible float processing
- Channel position mapping: MONO, stereo, 5.1, 7.1, plus Aux for N>8
- Follows the same buffer management pattern as AlsaDriverImpl
- Channel routing: main/aux input/output mapping with mix ops
- CLI flag: --driver pipewire|alsa (default: alsa)
- AudioHost: conditional driver selection based on driverType_
- PiPedalModel: stores and passes driver type to AudioHost
- CMakeLists.txt: added PipeWireDriver source files
2026-06-20 16:03:16 -04:00
shawn 524f02ec9d fix: resolve TS6133 unused-variable errors in MixerScenePanel files
- Removed unused Theme, DeleteIcon imports; fixed unused 'scenes' destructure
- Removed unused React default import and Restore icon import in subdir version
2026-06-20 16:00:33 -04:00
shawn e4e7cd1ca2 feat: add mixer scene API methods to PiPedalModel 2026-06-20 15:57:40 -04:00
shawn 0316e4b37f feat: add React mixer UI — MixerPage, ChannelStrip, MasterBus, useMixerWS hook
- useMixerWS: WebSocket hook connecting to ws://192.168.0.245:8080/ws
  with PiPedalSocket-compatible protocol (request/reply via replyTo)
  and auto-reconnect with exponential backoff.
- ChannelStrip: per-input channel control with volume fader, pan,
  mute, solo, and channel label/type display.
- MasterBus: master/subgroup/aux bus strip with volume fader and mute.
- MixerPage: full mixer console view with horizontal strip layout,
  real-time state polling (2s), and connection status indicator.
- AppThemed: added 'Mixer Console' drawer item + conditional rendering.

Build verified: tsc clean, vite build passes (1760 modules, 2.63s).
2026-06-20 15:56:17 -04:00
shawn 0a76f5734f Add scene save/load WS messages to MixerApi
- Implement saveScene: captures full mixer state via getStateJson(),
  stores in ~/op-pedal/default_config/scenes.json with auto-incrementing ID
- Implement loadScene: manual JSON walk to restore channel/bus state
  from saved scene JSON, routes skipped for safety
- Implement listScenes: returns JSON array of {id, name}
- Implement deleteScene: remove by ID from scenes file
- Add WS handlers: mixerSaveScene, mixerLoadScene, mixerGetScenes
  registered in PiPedalSocket.cpp message dispatcher
- Uses project's JSON_MAP/raw_json_string for file persistence
- Compiles cleanly against existing build
2026-06-20 15:54:42 -04:00
shawn 959da00d7c docs: add band-in-a-box digital mixer implementation plan 2026-06-20 15:11:14 -04:00
shawn 1854d03c58 feat: add WebsSocket API for mixer engine control
New files:
- MixerApi.hpp/cpp: Model-level API bridging WebSocket messages to
  MixerEngine control (channel volume/pan/mute/solo/hpf, bus control,
  routing, state queries)
- 15 new WebSocket message handlers in PiPedalSocket.cpp for full
  mixer control surface

Integration:
- MixerEngine member added to PiPedalModel with Get/Set accessors
- SetMixerEngine propagates to AudioHost rt processing pipeline
- Socket handler auto-wires MixerEngine to MixerApi on connect

Messages implemented:
  mixerSetChannelVolume, mixerSetChannelPan, mixerSetChannelMute,
  mixerSetChannelSolo, mixerSetChannelLabel, mixerSetChannelHpf,
  mixerGetState, mixerAddChannel, mixerRemoveChannel,
  mixerSetBusVolume, mixerSetBusMute, mixerAddBus, mixerRemoveBus,
  mixerRouteChannelToBus
2026-06-20 14:15:37 -04:00
shawn 0422c91b4e feat: integrate MixerEngine into AudioHost real-time audio pipeline
When SetMixerEngine() is called with a MixerEngine instance, the
audio processing thread routes all device input/output channels
through the mixer (channel strips → buses → master) instead of
the legacy Lv2Pedalboard. Falls back when no mixer engine is set.

- Thread-safe via mutex + realtime raw pointer swap (same pattern
  as existing SetPedalboard)
- Preserves existing pedalboard processing — both modes coexist
- New ProcessLv2Pedalboard() path when realtimeActiveMixerEngine
  is non-null uses device buffers directly for multi-channel I/O
2026-06-20 14:06:46 -04:00
shawn df5a317ceb feat: add MixerEngine core — ChannelStrip, MixerBus, and MixerEngine for band-in-a-box digital mixer
MixerEngine architecture:
- MixerChannelStrip: per-input FX chain (Lv2Pedalboard reuse),
  volume, pan, mute, solo, HPF, aux sends, VU metering
- MixerBus: accumulation bus with volume, mute, VU. Supports
  master, subgroup, aux, and FX-return bus types
- MixerEngine: orchestrator managing channel→bus routing graph,
  bus→bus routing, solo override, and the full real-time audio
  processing cycle

All new code compiles cleanly with the existing C++20 build and
follows the existing PiPedal codebase conventions (namespaces,
error handling, buffer patterns). CPU-efficient real-time thread
processing with atomic control surface interaction.
2026-06-20 13:57:15 -04:00
34 changed files with 8782 additions and 9 deletions
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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;
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+9
View File
@@ -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}
)
+10
View File
@@ -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;
+114
View File
@@ -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;
}
+66
View File
@@ -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
+415
View File
@@ -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;
}
+149
View File
@@ -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
+754
View File
@@ -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);
}
+165
View File
@@ -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
+174
View File
@@ -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;
}
}
+124
View File
@@ -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
+280
View File
@@ -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();
}
+229
View File
@@ -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
+656
View File
@@ -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;
}
+285
View File
@@ -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
View File
@@ -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:
+7
View File
@@ -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);
+291
View File
@@ -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;
+911
View File
@@ -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
+33
View File
@@ -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);
}
+17
View File
@@ -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);
+18 -1
View File
@@ -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)" }}
+465
View File
@@ -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>
);
}
}
+33
View File
@@ -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;
+228
View File
@@ -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;
+163
View File
@@ -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;
+713
View File
@@ -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>
</>
);
}
+335
View File
@@ -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;
+385
View File
@@ -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>
);
}
+289
View File
@@ -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 };
}