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:
@@ -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
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()) + "\"}";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user