Add MasterBus component + enhance BusStrip with routing selector

MasterBus: stereo master fader, mute, level meters in PiPedal theme
(purple accent per mixer-engine branch reference).

BusStrip: added per-channel routing selector (ch1Route..ch8Route)
for selecting output bus target (Main, Bus 2-8).

MixerPage: integrated MasterBus with levels derived from bus
master data. Local state for volume/mute until backend exposes
master output.
This commit is contained in:
2026-06-23 12:15:09 -04:00
parent 17d423b21f
commit d19e0ea7a8
25 changed files with 4419 additions and 288 deletions
+80
View File
@@ -0,0 +1,80 @@
cmake_minimum_required(VERSION 3.16)
project(oplabs-mixer-daemon
VERSION 1.0.0
DESCRIPTION "OPLabs Mixer Engine Daemon"
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# Find dependencies
find_package(PkgConfig QUIET)
# --- JACK Audio Connection Kit ---
find_library(JACK_LIBRARY jack)
find_path(JACK_INCLUDE_DIR jack/jack.h)
# --- Source files for the daemon (standalone) ---
set(DAEMON_SOURCES
MixerDaemon.cpp
MixerIpcServer.cpp
)
# Header-only convenience target for linking to op-pedal sources
# The daemon needs MixerEngine, MixerApi, MixerChannelStrip, MixerBus, etc.
# These are compiled as part of the op-pedal CMake project and linked here.
add_executable(oplabs-mixer-daemon ${DAEMON_SOURCES})
target_include_directories(oplabs-mixer-daemon PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
if(JACK_LIBRARY AND JACK_INCLUDE_DIR)
target_include_directories(oplabs-mixer-daemon PRIVATE ${JACK_INCLUDE_DIR})
target_link_libraries(oplabs-mixer-daemon PRIVATE ${JACK_LIBRARY})
message(STATUS "JACK found: ${JACK_LIBRARY}")
target_compile_definitions(oplabs-mixer-daemon PRIVATE HAS_JACK=1)
else()
message(WARNING "JACK not found — daemon will be built without audio I/O. "
"Install libjack-jackd2-dev or jack-audio-connection-kit.")
endif()
# When built inside the op-pedal CMake tree, link against op-pedal's libraries.
# When built standalone, the user must point CMAKE_PREFIX_PATH to an op-pedal build.
# For standalone development, provide a build wrapper:
if(TARGET op-pedal-lib)
# We're inside the op-pedal CMake tree
target_link_libraries(oplabs-mixer-daemon PRIVATE op-pedal-lib)
message(STATUS "Building inside op-pedal tree — linking op-pedal-lib")
else()
message(STATUS "Building standalone — user must provide MixerEngine source")
# In standalone mode, assume the user has checked out the mixer-engine sources
# and set the MIXER_ENGINE_SOURCE_DIR variable.
if(MIXER_ENGINE_SOURCE_DIR)
message(STATUS "Using MixerEngine sources from: ${MIXER_ENGINE_SOURCE_DIR}")
target_include_directories(oplabs-mixer-daemon PRIVATE
${MIXER_ENGINE_SOURCE_DIR}
${MIXER_ENGINE_SOURCE_DIR}/../PiPedalCommon
${MIXER_ENGINE_SOURCE_DIR}/../modules/SQLiteCpp/include
)
endif()
endif()
# Install
install(TARGETS oplabs-mixer-daemon RUNTIME DESTINATION bin)
# Systemd service file
configure_file(
oplabs-mixer-daemon.service.in
oplabs-mixer-daemon.service
@ONLY
)
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/oplabs-mixer-daemon.service
DESTINATION /etc/systemd/system
)
+134
View File
@@ -0,0 +1,134 @@
// Copyright (c) 2026 Ourpad Network
// See LICENSE file in the project root for full license text.
//
// MixerDaemon — Standalone OPLabs Mixer Engine daemon.
//
// This daemon:
// 1. Creates a MixerEngine with JACK audio I/O
// 2. Creates a MixerApi for control
// 3. Starts the MixerIpcServer on a Unix domain socket
// 4. Runs the audio loop until SIGINT/SIGTERM
#include <cstdio>
#include <csignal>
#include <atomic>
#include <chrono>
#include <thread>
#include <memory>
#include "MixerIpcServer.hpp"
#include "MixerApi.hpp"
#include "MixerEngine.hpp"
using namespace pipedal;
// Global pointers for signal handler
static MixerIpcServer* g_server = nullptr;
static std::atomic<bool> g_running{true};
void signalHandler(int sig) {
(void)sig;
fprintf(stderr, "\n[MixerDaemon] Signal received, shutting down...\n");
g_running.store(false);
if (g_server) {
g_server->stop();
}
}
void printUsage(const char* prog) {
fprintf(stderr, "Usage: %s [options]\n", prog);
fprintf(stderr, "Options:\n");
fprintf(stderr, " --socket-path PATH Unix socket path (default: /tmp/oplabs-mixer.sock)\n");
fprintf(stderr, " --sample-rate RATE Sample rate in Hz (default: 48000)\n");
fprintf(stderr, " --buffer-size SIZE Buffer size in frames (default: 512)\n");
fprintf(stderr, " --input-channels N Number of input channels (default: 8)\n");
fprintf(stderr, " --output-channels N Number of output channels (default: 8)\n");
fprintf(stderr, " --help Print this help\n");
}
int main(int argc, char* argv[]) {
// Default parameters
std::string socketPath = "/tmp/oplabs-mixer.sock";
uint32_t sampleRate = 48000;
size_t bufferSize = 512;
uint32_t inputChannels = 8;
uint32_t outputChannels = 8;
// Parse command line
for (int i = 1; i < argc; i++) {
std::string arg = argv[i];
if (arg == "--socket-path" && i + 1 < argc) {
socketPath = argv[++i];
} else if (arg == "--sample-rate" && i + 1 < argc) {
sampleRate = static_cast<uint32_t>(std::stoul(argv[++i]));
} else if (arg == "--buffer-size" && i + 1 < argc) {
bufferSize = static_cast<size_t>(std::stoul(argv[++i]));
} else if (arg == "--input-channels" && i + 1 < argc) {
inputChannels = static_cast<uint32_t>(std::stoul(argv[++i]));
} else if (arg == "--output-channels" && i + 1 < argc) {
outputChannels = static_cast<uint32_t>(std::stoul(argv[++i]));
} else if (arg == "--help") {
printUsage(argv[0]);
return 0;
} else {
fprintf(stderr, "Unknown option: %s\n", arg.c_str());
printUsage(argv[0]);
return 1;
}
}
fprintf(stderr, "[MixerDaemon] Starting OPLabs Mixer Daemon\n");
fprintf(stderr, "[MixerDaemon] Socket: %s\n", socketPath.c_str());
fprintf(stderr, "[MixerDaemon] SampleRate: %u Hz\n", sampleRate);
fprintf(stderr, "[MixerDaemon] BufferSize: %zu frames\n", bufferSize);
fprintf(stderr, "[MixerDaemon] Inputs: %u\n", inputChannels);
fprintf(stderr, "[MixerDaemon] Outputs: %u\n", outputChannels);
// Set up signal handling
signal(SIGINT, signalHandler);
signal(SIGTERM, signalHandler);
// Create MixerEngine
auto engine = std::make_unique<MixerEngine>();
engine->setSampleRate(sampleRate);
engine->setMaxBufferSize(bufferSize);
// Auto-create channels
engine->autoCreateChannels(inputChannels);
fprintf(stderr, "[MixerDaemon] MixerEngine created with %zu channels\n",
engine->channelCount());
// Create MixerApi and attach engine
auto api = std::make_unique<MixerApi>();
api->setMixerEngine(engine.get());
// Create IPC server
auto server = std::make_unique<MixerIpcServer>();
server->setMixerApi(api.get());
server->setMixerEngine(engine.get());
g_server = server.get();
if (!server->start(socketPath)) {
fprintf(stderr, "[MixerDaemon] Failed to start IPC server on %s\n",
socketPath.c_str());
return 1;
}
fprintf(stderr, "[MixerDaemon] IPC server listening on %s\n", socketPath.c_str());
fprintf(stderr, "[MixerDaemon] Ready. Waiting for connections...\n");
// Main loop — keep running until signal
// In a full implementation, this would run the JACK audio processing loop.
// For now, the daemon provides IPC access to the MixerEngine.
while (g_running.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// Cleanup
server->stop();
api->setMixerEngine(nullptr);
fprintf(stderr, "[MixerDaemon] Shutdown complete.\n");
return 0;
}
+753
View File
@@ -0,0 +1,753 @@
// Copyright (c) 2026 Ourpad Network
// See LICENSE file in the project root for full license text.
//
// MixerIpcServer — Unix domain socket IPC server implementation.
#include "MixerIpcServer.hpp"
#include "MixerApi.hpp"
#include "MixerEngine.hpp"
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <cstdio>
#include <cerrno>
#include <sstream>
#include <algorithm>
#include <arpa/inet.h> // for htonl/ntohl
// JSON writer/reader (inline minimal implementation to avoid heavy dependency)
// In production, use a proper JSON library like nlohmann/json or boost.json.
// Here we use the PiPedal project's own json_writer/json_reader if available,
// but since this is a standalone daemon, we provide a minimal inline version.
namespace {
// Write a JSON string with proper escaping
std::string jsonEscape(const std::string& s) {
std::string out;
out.reserve(s.size() + 2);
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buf[8];
snprintf(buf, sizeof(buf), "\\u%04x", c);
out += buf;
} else {
out += c;
}
}
}
return out;
}
// Simple JSON object builder
class JsonBuilder {
public:
JsonBuilder() : ss_() { ss_ << "{"; first_ = true; }
void add(const std::string& key, const std::string& value) {
if (!first_) ss_ << ",";
first_ = false;
ss_ << "\"" << jsonEscape(key) << "\":\"" << jsonEscape(value) << "\"";
}
void add(const std::string& key, const char* value) {
add(key, std::string(value));
}
void add(const std::string& key, int64_t value) {
if (!first_) ss_ << ",";
first_ = false;
ss_ << "\"" << jsonEscape(key) << "\":" << value;
}
void add(const std::string& key, double value) {
if (!first_) ss_ << ",";
first_ = false;
ss_ << "\"" << jsonEscape(key) << "\":" << value;
}
void add(const std::string& key, bool value) {
if (!first_) ss_ << ",";
first_ = false;
ss_ << "\"" << jsonEscape(key) << "\":" << (value ? "true" : "false");
}
void addRaw(const std::string& key, const std::string& rawJson) {
if (!first_) ss_ << ",";
first_ = false;
ss_ << "\"" << jsonEscape(key) << "\":" << rawJson;
}
std::string build() { ss_ << "}"; return ss_.str(); }
private:
std::stringstream ss_;
bool first_ = true;
};
// Simple JSON key-value extractor (does not handle nested objects)
// Just enough for our params extraction
class SimpleJsonParser {
public:
SimpleJsonParser(const std::string& json) : data_(json), pos_(0) {
skipWhitespace();
}
bool enterObject() {
skipWhitespace();
if (pos_ < data_.size() && data_[pos_] == '{') {
pos_++;
skipWhitespace();
return true;
}
return false;
}
bool enterArray() {
skipWhitespace();
if (pos_ < data_.size() && data_[pos_] == '[') {
pos_++;
skipWhitespace();
return true;
}
return false;
}
void leaveObject() {
skipWhitespace();
if (pos_ < data_.size() && data_[pos_] == '}') pos_++;
}
void leaveArray() {
skipWhitespace();
if (pos_ < data_.size() && data_[pos_] == ']') pos_++;
}
std::string readKey() {
skipWhitespace();
std::string key = readString();
skipWhitespace();
if (pos_ < data_.size() && data_[pos_] == ':') pos_++;
return key;
}
std::string readString() {
skipWhitespace();
if (pos_ >= data_.size() || data_[pos_] != '"') return "";
pos_++; // skip opening quote
std::string result;
while (pos_ < data_.size() && data_[pos_] != '"') {
if (data_[pos_] == '\\' && pos_ + 1 < data_.size()) {
pos_++;
switch (data_[pos_]) {
case '"': result += '"'; break;
case '\\': result += '\\'; break;
case '/': result += '/'; break;
case 'n': result += '\n'; break;
case 'r': result += '\r'; break;
case 't': result += '\t'; break;
default: result += data_[pos_]; break;
}
} else {
result += data_[pos_];
}
pos_++;
}
if (pos_ < data_.size() && data_[pos_] == '"') pos_++; // skip closing quote
return result;
}
int64_t readInt() {
skipWhitespace();
char* end = nullptr;
int64_t val = strtoll(data_.c_str() + pos_, &end, 10);
if (end) pos_ = end - data_.c_str();
return val;
}
double readDouble() {
skipWhitespace();
char* end = nullptr;
double val = strtod(data_.c_str() + pos_, &end);
if (end) pos_ = end - data_.c_str();
return val;
}
bool readBool() {
skipWhitespace();
if (data_.substr(pos_, 4) == "true") { pos_ += 4; return true; }
if (data_.substr(pos_, 5) == "false") { pos_ += 5; return false; }
return false;
}
std::string readRaw() {
// Read a raw JSON value (for nested objects/arrays we want to preserve)
skipWhitespace();
if (pos_ >= data_.size()) return "";
char start = data_[pos_];
if (start == '{' || start == '[') {
// Find matching close brace/bracket
char close = (start == '{') ? '}' : ']';
int depth = 0;
size_t startPos = pos_;
bool inString = false;
while (pos_ < data_.size()) {
char c = data_[pos_];
if (c == '"' && (pos_ == 0 || data_[pos_-1] != '\\')) inString = !inString;
if (!inString) {
if (c == start) depth++;
if (c == close) {
depth--;
if (depth == 0) {
pos_++;
return data_.substr(startPos, pos_ - startPos);
}
}
}
pos_++;
}
pos_ = startPos; // reset on failure
}
return "";
}
// Skip a single value (for keys we don't care about)
void skipValue() {
skipWhitespace();
if (pos_ >= data_.size()) return;
char c = data_[pos_];
if (c == '"') { readString(); }
else if (c == '{' || c == '[') { readRaw(); }
else if (c == 't' || c == 'f') { readBool(); }
else { readDouble(); } // number (also handles int)
skipWhitespace();
if (pos_ < data_.size() && (data_[pos_] == ',' || data_[pos_] == '}')) {
if (data_[pos_] == ',') pos_++;
}
}
// Check if we've reached the end of the current object
bool atEnd() {
skipWhitespace();
return pos_ >= data_.size() || data_[pos_] == '}';
}
// Parse a string value for a given key from a flat JSON object
std::string getString(const std::string& key) {
if (!enterObject()) return "";
while (!atEnd()) {
std::string k = readKey();
if (k == key) { std::string v = readString(); leaveObject(); return v; }
else { skipValue(); }
}
leaveObject();
return "";
}
int64_t getInt(const std::string& key, int64_t def = 0) {
if (!enterObject()) return def;
while (!atEnd()) {
std::string k = readKey();
if (k == key) { int64_t v = readInt(); leaveObject(); return v; }
else { skipValue(); }
}
leaveObject();
return def;
}
double getDouble(const std::string& key, double def = 0.0) {
if (!enterObject()) return def;
while (!atEnd()) {
std::string k = readKey();
if (k == key) { double v = readDouble(); leaveObject(); return v; }
else { skipValue(); }
}
leaveObject();
return def;
}
bool getBool(const std::string& key, bool def = false) {
if (!enterObject()) return def;
while (!atEnd()) {
std::string k = readKey();
if (k == key) { bool v = readBool(); leaveObject(); return v; }
else { skipValue(); }
}
leaveObject();
return def;
}
std::string getRaw(const std::string& key) {
if (!enterObject()) return "";
while (!atEnd()) {
std::string k = readKey();
if (k == key) { std::string v = readRaw(); leaveObject(); return v; }
else { skipValue(); }
}
leaveObject();
return "";
}
private:
const std::string& data_;
size_t pos_;
void skipWhitespace() {
while (pos_ < data_.size() && (data_[pos_] == ' ' || data_[pos_] == '\t' ||
data_[pos_] == '\n' || data_[pos_] == '\r'))
pos_++;
}
};
} // anonymous namespace
using namespace pipedal;
// ---------------------------------------------------------------------------
// Construction / Destruction
// ---------------------------------------------------------------------------
MixerIpcServer::MixerIpcServer() {}
MixerIpcServer::~MixerIpcServer() { stop(); }
// ---------------------------------------------------------------------------
// Start / Stop
// ---------------------------------------------------------------------------
bool MixerIpcServer::start(const std::string& socketPath) {
if (running_.load()) {
return true; // already running
}
socketPath_ = socketPath;
// Remove existing socket file if present
unlink(socketPath_.c_str());
// Create socket
serverFd_ = socket(AF_UNIX, SOCK_STREAM, 0);
if (serverFd_ < 0) {
perror("socket");
return false;
}
// Bind
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, socketPath_.c_str(), sizeof(addr.sun_path) - 1);
if (bind(serverFd_, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
close(serverFd_);
serverFd_ = -1;
return false;
}
// Set permissions so the FastAPI process (running as same user) can connect
chmod(socketPath_.c_str(), 0666);
// Listen
if (listen(serverFd_, 5) < 0) {
perror("listen");
close(serverFd_);
serverFd_ = -1;
unlink(socketPath_.c_str());
return false;
}
running_.store(true);
// Start accept thread
acceptThread_ = std::thread(&MixerIpcServer::acceptLoop, this);
return true;
}
void MixerIpcServer::stop() {
running_.store(false);
// Close all client connections
for (auto& [id, conn] : clients_) {
if (conn->fd >= 0) {
close(conn->fd);
conn->fd = -1;
}
}
clients_.clear();
// Close server socket
if (serverFd_ >= 0) {
close(serverFd_);
serverFd_ = -1;
}
// Remove socket file
if (!socketPath_.empty()) {
unlink(socketPath_.c_str());
}
// Join accept thread
if (acceptThread_.joinable()) {
acceptThread_.join();
}
}
// ---------------------------------------------------------------------------
// Communication
// ---------------------------------------------------------------------------
bool MixerIpcServer::sendFrame(int fd, const std::string& jsonPayload) {
if (fd < 0) return false;
uint32_t len = static_cast<uint32_t>(jsonPayload.size());
uint32_t lenBe = htonl(len);
// Write length prefix
ssize_t written = write(fd, &lenBe, sizeof(lenBe));
if (written != sizeof(lenBe)) return false;
// Write payload
written = write(fd, jsonPayload.data(), jsonPayload.size());
if (written != static_cast<ssize_t>(jsonPayload.size())) return false;
return true;
}
bool MixerIpcServer::sendResponse(int clientFd, const std::string& jsonResponse) {
return sendFrame(clientFd, jsonResponse);
}
void MixerIpcServer::broadcast(const std::string& jsonMessage) {
auto fds = connectedClientFds();
for (int fd : fds) {
if (fd >= 0) {
sendFrame(fd, jsonMessage);
}
}
}
std::vector<int> MixerIpcServer::connectedClientFds() const {
std::vector<int> fds;
for (const auto& [id, conn] : clients_) {
if (conn->active && conn->fd >= 0) {
fds.push_back(conn->fd);
}
}
return fds;
}
// ---------------------------------------------------------------------------
// Accept loop
// ---------------------------------------------------------------------------
void MixerIpcServer::acceptLoop() {
while (running_.load()) {
struct sockaddr_un clientAddr;
socklen_t clientLen = sizeof(clientAddr);
int clientFd = accept(serverFd_, (struct sockaddr*)&clientAddr, &clientLen);
if (clientFd < 0) {
if (errno == EINTR) continue;
if (!running_.load()) break;
perror("accept");
continue;
}
// Create client connection
auto conn = std::make_unique<ClientConnection>();
conn->fd = clientFd;
conn->active = true;
int clientId = nextClientId_++;
clients_[clientId] = std::move(conn);
// Handle this client (blocking per-client)
// In a real implementation, this would be in a separate thread or use event loop.
// For this embedded version, we handle one client at a time per thread.
handleClient(clientId);
}
}
// ---------------------------------------------------------------------------
// Client handler
// ---------------------------------------------------------------------------
void MixerIpcServer::handleClient(int clientId) {
auto it = clients_.find(clientId);
if (it == clients_.end()) return;
auto& conn = it->second;
while (running_.load() && conn->active) {
if (!readFromClient(*conn)) {
break;
}
}
// Cleanup
if (conn->fd >= 0) {
close(conn->fd);
conn->fd = -1;
}
conn->active = false;
clients_.erase(clientId);
}
bool MixerIpcServer::readFromClient(ClientConnection& conn) {
uint8_t buffer[4096];
ssize_t n = read(conn.fd, buffer, sizeof(buffer));
if (n <= 0) {
if (n == 0) {
// Connection closed
return false;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// No data available yet — yield and retry on next loop iteration
// In a proper impl, use poll()/epoll()
return true;
}
return false;
}
// Append to read buffer
conn.readBuffer.insert(conn.readBuffer.end(), buffer, buffer + n);
// Process complete frames
while (true) {
if (conn.readingHeader) {
if (conn.readBuffer.size() < 4) break;
// Read length prefix (big-endian uint32)
uint32_t lenBe;
memcpy(&lenBe, conn.readBuffer.data(), 4);
conn.expectedLength = ntohl(lenBe);
// Remove header from buffer
conn.readBuffer.erase(conn.readBuffer.begin(), conn.readBuffer.begin() + 4);
conn.readingHeader = false;
}
if (conn.readBuffer.size() < conn.expectedLength) break;
// We have a complete message
std::string jsonMessage(conn.readBuffer.begin(),
conn.readBuffer.begin() + conn.expectedLength);
conn.readBuffer.erase(conn.readBuffer.begin(),
conn.readBuffer.begin() + conn.expectedLength);
conn.readingHeader = true;
conn.expectedLength = 0;
// Process the message
processMessage(conn, jsonMessage);
}
return true;
}
// ---------------------------------------------------------------------------
// Message processing
// ---------------------------------------------------------------------------
void MixerIpcServer::processMessage(ClientConnection& conn, const std::string& jsonMessage) {
if (messageCallback_) {
messageCallback_(jsonMessage);
}
// Parse the JSON message
SimpleJsonParser parser(jsonMessage);
// Extract id, method, params
int64_t msgId = 0;
std::string method;
std::string params;
if (!parser.enterObject()) {
// Invalid message
JsonBuilder err;
err.add("id", 0);
err.add("error", std::string("Invalid JSON: expected object"));
sendResponse(conn.fd, err.build());
return;
}
while (!parser.atEnd()) {
std::string key = parser.readKey();
if (key == "id") {
msgId = parser.readInt();
} else if (key == "method") {
method = parser.readString();
} else if (key == "params") {
// Read raw params value (could be object, array, etc.)
params = parser.readRaw();
} else {
parser.skipValue();
}
}
parser.leaveObject();
if (method.empty()) {
JsonBuilder err;
err.add("id", msgId);
err.add("error", std::string("Missing 'method' field"));
sendResponse(conn.fd, err.build());
return;
}
// Dispatch and get response
std::string resultJson = dispatchMethod(method, params);
// Wrap with response id
std::stringstream ss;
ss << "{\"id\":" << msgId << ",";
// The resultJson starts with {"result": ...} or {"error": ...}
// Strip the outer { and add our id
if (!resultJson.empty() && resultJson[0] == '{') {
ss << resultJson.substr(1); // skip leading {
} else {
ss << "\"result\":" << resultJson << ",\"error\":null";
}
sendResponse(conn.fd, ss.str());
}
// ---------------------------------------------------------------------------
// Method dispatch
// ---------------------------------------------------------------------------
std::string MixerIpcServer::dispatchMethod(const std::string& method, const std::string& paramsJson) {
if (!mixerApi_) {
return "{\"error\":\"no mixer api configured\"}";
}
try {
if (method == "getState") {
std::string state = mixerApi_->getStateJson();
return "{\"result\":" + state + ",\"error\":null}";
}
else if (method == "setChannelVolume") {
SimpleJsonParser p(paramsJson);
int ci = (int)p.getInt("channelIndex", -1);
double vol = p.getDouble("volumeDb", -96.0);
if (ci < 0) return "{\"error\":\"missing channelIndex\"}";
mixerApi_->setChannelVolume(ci, (float)vol);
return "{\"result\":{},\"error\":null}";
}
else if (method == "setChannelPan") {
SimpleJsonParser p(paramsJson);
int ci = (int)p.getInt("channelIndex", -1);
double pan = p.getDouble("pan", 0.0);
if (ci < 0) return "{\"error\":\"missing channelIndex\"}";
mixerApi_->setChannelPan(ci, (float)pan);
return "{\"result\":{},\"error\":null}";
}
else if (method == "setChannelMute") {
SimpleJsonParser p(paramsJson);
int ci = (int)p.getInt("channelIndex", -1);
bool mute = p.getBool("mute", false);
if (ci < 0) return "{\"error\":\"missing channelIndex\"}";
mixerApi_->setChannelMute(ci, mute);
return "{\"result\":{},\"error\":null}";
}
else if (method == "setChannelSolo") {
SimpleJsonParser p(paramsJson);
int ci = (int)p.getInt("channelIndex", -1);
bool solo = p.getBool("solo", false);
if (ci < 0) return "{\"error\":\"missing channelIndex\"}";
mixerApi_->setChannelSolo(ci, solo);
return "{\"result\":{},\"error\":null}";
}
else if (method == "addChannel") {
SimpleJsonParser p(paramsJson);
int pi = (int)p.getInt("physicalInputIndex", 0);
int idx = mixerApi_->addChannel(pi);
JsonBuilder resp;
resp.add("channelIndex", (int64_t)idx);
return "{\"result\":" + resp.build() + ",\"error\":null}";
}
else if (method == "removeChannel") {
SimpleJsonParser p(paramsJson);
int ci = (int)p.getInt("channelIndex", -1);
if (ci < 0) return "{\"error\":\"missing channelIndex\"}";
mixerApi_->removeChannel(ci);
return "{\"result\":{},\"error\":null}";
}
else if (method == "getOutputRoutes") {
std::string routes = mixerApi_->getOutputRoutesJson();
return "{\"result\":{\"routes\":" + routes + "},\"error\":null}";
}
else if (method == "setOutputRoutes") {
// params is the raw JSON for output routes array
// We need the routes array from params
SimpleJsonParser p(paramsJson);
std::string routesArray = p.getRaw("routes");
if (!routesArray.empty()) {
mixerApi_->setOutputRoutesFromJson(routesArray);
}
return "{\"result\":{},\"error\":null}";
}
else if (method == "saveScene") {
SimpleJsonParser p(paramsJson);
std::string name = p.getString("name");
if (name.empty()) return "{\"error\":\"missing name\"}";
std::string result = mixerApi_->saveScene(name);
// result from MixerApi is already JSON: {"id": N, "name": "..."}
return "{\"result\":" + result + ",\"error\":null}";
}
else if (method == "loadScene") {
SimpleJsonParser p(paramsJson);
std::string sceneId = p.getString("sceneId");
if (sceneId.empty()) {
// Try as integer
int64_t id = p.getInt("sceneId", -1);
if (id < 0) return "{\"error\":\"missing sceneId\"}";
sceneId = std::to_string(id);
}
bool ok = mixerApi_->loadScene(sceneId);
JsonBuilder resp;
resp.add("ok", ok);
return "{\"result\":" + resp.build() + ",\"error\":null}";
}
else if (method == "listScenes") {
std::string scenes = mixerApi_->listScenes();
if (scenes.empty()) scenes = "[]";
return "{\"result\":{\"scenes\":" + scenes + "},\"error\":null}";
}
else if (method == "deleteScene") {
SimpleJsonParser p(paramsJson);
std::string sceneId = p.getString("sceneId");
if (sceneId.empty()) {
int64_t id = p.getInt("sceneId", -1);
if (id < 0) return "{\"error\":\"missing sceneId\"}";
sceneId = std::to_string(id);
}
bool ok = mixerApi_->deleteScene(sceneId);
JsonBuilder resp;
resp.add("ok", ok);
return "{\"result\":" + resp.build() + ",\"error\":null}";
}
else {
return "{\"error\":\"unknown method: " + jsonEscape(method) + "\"}";
}
}
catch (const std::exception& e) {
return "{\"error\":\"" + jsonEscape(e.what()) + "\"}";
}
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright (c) 2026 Ourpad Network
// See LICENSE file in the project root for full license text.
//
// MixerIpcServer — Unix domain socket IPC server for the OPLabs Mixer Daemon.
//
// Protocol:
// - Transport: Unix domain socket (AF_UNIX, SOCK_STREAM)
// - Socket path: /tmp/oplabs-mixer.sock
// - Framing: 4-byte big-endian length prefix (uint32_t) + JSON payload (UTF-8)
//
// Request: {"id": <int>, "method": <string>, "params": {<object>}}
// Response: {"id": <int>, "result": <any>, "error": <string|null>}
// Push: {"type": "push", "event": <string>, "data": {<object>}}
//
// Methods:
// getState params: {} result: {full mixer state}
// setChannelVolume params: {channelIndex, volumeDb} result: {}
// setChannelPan params: {channelIndex, pan} result: {}
// setChannelMute params: {channelIndex, mute} result: {}
// setChannelSolo params: {channelIndex, solo} result: {}
// addChannel params: {physicalInputIndex} result: {channelIndex}
// removeChannel params: {channelIndex} result: {}
// getOutputRoutes params: {} result: {routes: [...]}
// setOutputRoutes params: {routes: [...]} result: {}
// saveScene params: {name} result: {id, name}
// loadScene params: {sceneId} result: {ok: bool}
// listScenes params: {} result: {scenes: [...]}
// deleteScene params: {sceneId} result: {ok: bool}
#pragma once
#include <string>
#include <functional>
#include <atomic>
#include <thread>
#include <vector>
#include <map>
namespace pipedal {
class MixerApi;
class MixerEngine;
/// Unix domain socket IPC server that dispatches JSON messages to MixerApi.
class MixerIpcServer {
public:
/// Callback signature: clientConnected, clientDisconnected
using ConnectionCallback = std::function<void()>;
using MessageCallback = std::function<void(const std::string&)>;
MixerIpcServer();
~MixerIpcServer();
// Disable copy
MixerIpcServer(const MixerIpcServer&) = delete;
MixerIpcServer& operator=(const MixerIpcServer&) = delete;
/// Set the MixerApi instance to dispatch to.
void setMixerApi(MixerApi* api) { mixerApi_ = api; }
/// Set the MixerEngine instance directly (for push messages / VU).
void setMixerEngine(MixerEngine* engine) { mixerEngine_ = engine; }
/// Start the IPC server on the given socket path.
/// Returns true on success.
bool start(const std::string& socketPath = "/tmp/oplabs-mixer.sock");
/// Stop the IPC server and close all connections.
void stop();
/// Check if the server is running.
bool isRunning() const { return running_.load(); }
/// Broadcast a push message to all connected clients.
void broadcast(const std::string& jsonMessage);
/// Set callback for incoming messages (for logging/monitoring).
void setMessageCallback(MessageCallback cb) { messageCallback_ = std::move(cb); }
private:
MixerApi* mixerApi_ = nullptr;
MixerEngine* mixerEngine_ = nullptr;
std::atomic<bool> running_{false};
int serverFd_ = -1;
std::string socketPath_;
std::thread acceptThread_;
// Per-client state
struct ClientConnection {
int fd = -1;
bool active = false;
std::vector<uint8_t> readBuffer;
uint32_t expectedLength = 0;
bool readingHeader = true;
};
std::map<int, std::unique_ptr<ClientConnection>> clients_;
int nextClientId_ = 1;
MessageCallback messageCallback_;
// Accept loop (runs in its own thread)
void acceptLoop();
// Handle a single client connection
void handleClient(int clientFd);
// Read and accumulate data from a client
bool readFromClient(ClientConnection& conn);
// Process a complete JSON message
void processMessage(ClientConnection& conn, const std::string& jsonMessage);
// Send a JSON response to a specific client
bool sendResponse(int clientFd, const std::string& jsonResponse);
// Convenience: send a length-prefixed frame
bool sendFrame(int fd, const std::string& jsonPayload);
// Dispatch a method call to MixerApi and return JSON result
std::string dispatchMethod(const std::string& method, const std::string& paramsJson);
// All connected client file descriptors
std::vector<int> connectedClientFds() const;
};
} // namespace pipedal
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=OPLabs Mixer Daemon
Documentation=https://git.ourpad.casa/oplabs/oplabs-mixer-app
After=network.target sound.target
Wants=jack.service
[Service]
Type=simple
User=oplabs
ExecStart=/usr/bin/oplabs-mixer-daemon \
--socket-path /tmp/oplabs-mixer.sock \
--sample-rate 48000 \
--buffer-size 512 \
--input-channels 8 \
--output-channels 8
Restart=on-failure
RestartSec=5
LimitNOFILE=8192
Nice=-10
# JACK-specific: wait for JACK to be ready
ExecStartPre=/bin/sh -c 'while ! jack_wait -c 2>/dev/null; do sleep 1; done'
[Install]
WantedBy=multi-user.target