Add shared memory daemon architecture and systemd service
Move ES-5 encoding from the CLAP plugin to a standalone PipeWire daemon (es5d) communicating via POSIX shared memory. The plugin now acts as a parameter/state frontend while es5d outputs directly to the ES-9. Includes systemd user service for autostart after reboot and an install script for local deployment.
This commit is contained in:
+218
@@ -0,0 +1,218 @@
|
||||
// ES-5 Daemon — reads gate/CV state from shared memory, outputs encoded
|
||||
// audio via PipeWire directly to the ES-9, bypassing Bitwig's audio engine.
|
||||
// Copyright (c) 2026 Sub-Net e.U. — MIT License
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <csignal>
|
||||
#include <algorithm>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <pipewire/pipewire.h>
|
||||
#include <spa/param/audio/format-utils.h>
|
||||
|
||||
#include "../src/es5_shared.h"
|
||||
#include "../src/encoders.h"
|
||||
|
||||
static pw_main_loop *loop = nullptr;
|
||||
|
||||
static void sighandler(int) {
|
||||
if (loop) pw_main_loop_quit(loop);
|
||||
}
|
||||
|
||||
struct AppData {
|
||||
ES5SharedState *shm;
|
||||
es5::ES5Encoder es5_enc;
|
||||
es5::ESX8GTEncoder gt_enc[ES5_NUM_HEADERS];
|
||||
es5::ESX8CVEncoder cv_enc[ES5_NUM_HEADERS];
|
||||
};
|
||||
|
||||
static void on_process(void *userdata) {
|
||||
auto *app = static_cast<AppData *>(userdata);
|
||||
auto *shm = app->shm;
|
||||
|
||||
pw_buffer *b = pw_stream_dequeue_buffer(static_cast<pw_stream *>(
|
||||
// stored in userdata via closure — we'll use a different approach
|
||||
nullptr));
|
||||
// This won't work — need stream reference. Use struct instead.
|
||||
}
|
||||
|
||||
// We need the stream in the callback, so use a full struct
|
||||
struct DaemonData {
|
||||
pw_main_loop *loop;
|
||||
pw_stream *stream;
|
||||
ES5SharedState *shm;
|
||||
es5::ES5Encoder es5_enc;
|
||||
es5::ESX8GTEncoder gt_enc[ES5_NUM_HEADERS];
|
||||
es5::ESX8CVEncoder cv_enc[ES5_NUM_HEADERS];
|
||||
uint32_t debug_count = 0;
|
||||
FILE *debug_file = nullptr;
|
||||
};
|
||||
|
||||
static void daemon_process(void *userdata) {
|
||||
auto *d = static_cast<DaemonData *>(userdata);
|
||||
|
||||
pw_buffer *b = pw_stream_dequeue_buffer(d->stream);
|
||||
if (!b) return;
|
||||
|
||||
spa_buffer *buf = b->buffer;
|
||||
float *dst = static_cast<float *>(buf->datas[0].data);
|
||||
if (!dst) return;
|
||||
|
||||
uint32_t n_frames = buf->datas[0].maxsize / (sizeof(float) * 2);
|
||||
if (b->requested && b->requested < n_frames)
|
||||
n_frames = b->requested;
|
||||
|
||||
for (uint32_t f = 0; f < n_frames; ++f) {
|
||||
uint8_t headers[6] = {};
|
||||
|
||||
for (int h = 0; h < ES5_NUM_HEADERS; ++h) {
|
||||
uint8_t type = d->shm->header_types[h].load(std::memory_order_relaxed);
|
||||
|
||||
if (type == 1) { // GT
|
||||
bool gates[8];
|
||||
for (int c = 0; c < 8; ++c)
|
||||
gates[c] = d->shm->values[h][c].load(std::memory_order_relaxed) > 0.5f;
|
||||
headers[h] = d->gt_enc[h].encode(gates);
|
||||
} else if (type == 2) { // CV
|
||||
double cvs[8];
|
||||
for (int c = 0; c < 8; ++c)
|
||||
cvs[c] = d->shm->values[h][c].load(std::memory_order_relaxed) * 4095.0 - 2048.0;
|
||||
headers[h] = d->cv_enc[h].encode(cvs);
|
||||
}
|
||||
}
|
||||
|
||||
float L, R;
|
||||
d->es5_enc.encode(headers, L, R);
|
||||
dst[f * 2 + 0] = L;
|
||||
dst[f * 2 + 1] = R;
|
||||
|
||||
// Debug: dump first 48000 samples
|
||||
if (d->debug_count < 48000) {
|
||||
if (!d->debug_file)
|
||||
d->debug_file = fopen("/tmp/es5d_debug.raw", "wb");
|
||||
if (d->debug_file) {
|
||||
fwrite(&L, 4, 1, d->debug_file);
|
||||
fwrite(&R, 4, 1, d->debug_file);
|
||||
fwrite(headers, 1, 6, d->debug_file);
|
||||
// Also write shm header types
|
||||
uint8_t types[6];
|
||||
for (int i = 0; i < 6; ++i)
|
||||
types[i] = d->shm->header_types[i].load(std::memory_order_relaxed);
|
||||
fwrite(types, 1, 6, d->debug_file);
|
||||
d->debug_count++;
|
||||
if (d->debug_count == 48000) {
|
||||
fclose(d->debug_file);
|
||||
d->debug_file = nullptr;
|
||||
fprintf(stderr, "es5d: debug dump written to /tmp/es5d_debug.raw\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buf->datas[0].chunk->offset = 0;
|
||||
buf->datas[0].chunk->stride = sizeof(float) * 2;
|
||||
buf->datas[0].chunk->size = n_frames * sizeof(float) * 2;
|
||||
|
||||
pw_stream_queue_buffer(d->stream, b);
|
||||
}
|
||||
|
||||
static const pw_stream_events stream_events = {
|
||||
.version = PW_VERSION_STREAM_EVENTS,
|
||||
.process = daemon_process,
|
||||
};
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
setbuf(stderr, nullptr);
|
||||
|
||||
// Open or create shared memory
|
||||
int fd = shm_open(ES5_SHM_NAME, O_CREAT | O_RDWR, 0644);
|
||||
if (fd < 0) {
|
||||
perror("shm_open");
|
||||
return 1;
|
||||
}
|
||||
ftruncate(fd, sizeof(ES5SharedState));
|
||||
auto *shm = static_cast<ES5SharedState *>(
|
||||
mmap(nullptr, sizeof(ES5SharedState), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0));
|
||||
close(fd);
|
||||
if (shm == MAP_FAILED) {
|
||||
perror("mmap");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Initialize shared memory
|
||||
memset(shm, 0, sizeof(ES5SharedState));
|
||||
|
||||
pw_init(&argc, &argv);
|
||||
|
||||
DaemonData data{};
|
||||
data.shm = shm;
|
||||
data.loop = pw_main_loop_new(nullptr);
|
||||
loop = data.loop;
|
||||
|
||||
signal(SIGINT, sighandler);
|
||||
signal(SIGTERM, sighandler);
|
||||
|
||||
auto *props = pw_properties_new(
|
||||
PW_KEY_MEDIA_TYPE, "Audio",
|
||||
PW_KEY_MEDIA_CATEGORY, "Playback",
|
||||
PW_KEY_MEDIA_ROLE, "Production",
|
||||
PW_KEY_NODE_NAME, "es5-encoder",
|
||||
PW_KEY_NODE_DESCRIPTION, "ES-5 Encoder (Direct)",
|
||||
PW_KEY_TARGET_OBJECT, "alsa_output.usb-Expert_Sleepers_Ltd_ES-9-01.multichannel-output",
|
||||
PW_KEY_NODE_WANT_DRIVER, "true",
|
||||
PW_KEY_NODE_ALWAYS_PROCESS, "true",
|
||||
nullptr
|
||||
);
|
||||
|
||||
data.stream = pw_stream_new_simple(
|
||||
pw_main_loop_get_loop(data.loop),
|
||||
"es5-encoder",
|
||||
props,
|
||||
&stream_events,
|
||||
&data
|
||||
);
|
||||
|
||||
uint8_t format_buf[1024];
|
||||
spa_pod_builder pod_builder = SPA_POD_BUILDER_INIT(format_buf, sizeof(format_buf));
|
||||
|
||||
spa_audio_info_raw audio_info = {};
|
||||
audio_info.format = SPA_AUDIO_FORMAT_F32;
|
||||
audio_info.rate = 48000;
|
||||
audio_info.channels = 2;
|
||||
audio_info.position[0] = SPA_AUDIO_CHANNEL_AUX12;
|
||||
audio_info.position[1] = SPA_AUDIO_CHANNEL_AUX13;
|
||||
|
||||
const spa_pod *params[1];
|
||||
params[0] = spa_format_audio_raw_build(&pod_builder, SPA_PARAM_EnumFormat, &audio_info);
|
||||
|
||||
pw_stream_connect(
|
||||
data.stream,
|
||||
PW_DIRECTION_OUTPUT,
|
||||
PW_ID_ANY,
|
||||
static_cast<pw_stream_flags>(
|
||||
PW_STREAM_FLAG_AUTOCONNECT |
|
||||
PW_STREAM_FLAG_MAP_BUFFERS |
|
||||
PW_STREAM_FLAG_RT_PROCESS),
|
||||
params, 1
|
||||
);
|
||||
|
||||
fprintf(stderr, "es5d: running — PipeWire → ES-9 AUX12/AUX13\n");
|
||||
fprintf(stderr, "es5d: shared memory at " ES5_SHM_NAME "\n");
|
||||
fprintf(stderr, "es5d: waiting for CLAP plugin to set header types...\n");
|
||||
|
||||
pw_main_loop_run(data.loop);
|
||||
|
||||
fprintf(stderr, "\nes5d: shutting down\n");
|
||||
pw_stream_destroy(data.stream);
|
||||
pw_main_loop_destroy(data.loop);
|
||||
pw_deinit();
|
||||
munmap(shm, sizeof(ES5SharedState));
|
||||
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user