Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d0ad127b5 |
@@ -53,7 +53,20 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
|
||||
)
|
||||
endif()
|
||||
|
||||
# ES-5 daemon (PipeWire output)
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(PIPEWIRE REQUIRED libpipewire-0.3)
|
||||
|
||||
add_executable(es5d tools/es5d.cpp)
|
||||
target_include_directories(es5d PRIVATE ${PIPEWIRE_INCLUDE_DIRS})
|
||||
target_link_libraries(es5d PRIVATE ${PIPEWIRE_LIBRARIES} rt)
|
||||
target_compile_options(es5d PRIVATE ${PIPEWIRE_CFLAGS_OTHER})
|
||||
endif()
|
||||
|
||||
# Install to standard CLAP paths
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
install(TARGETS ${PROJECT_NAME} LIBRARY DESTINATION lib/clap)
|
||||
install(TARGETS es5d RUNTIME DESTINATION bin)
|
||||
install(FILES dist/es5d.service DESTINATION lib/systemd/user)
|
||||
endif()
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=ES-5 Encoder Daemon (PipeWire)
|
||||
Documentation=https://git.sub-net.at/submodular/es5-clap
|
||||
After=pipewire.service
|
||||
Requires=pipewire.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/es5d
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BUILD_DIR="${SCRIPT_DIR}/build"
|
||||
|
||||
# Build if needed
|
||||
if [[ ! -f "${BUILD_DIR}/es5d" ]] || [[ ! -f "${BUILD_DIR}/es5-clap.clap" ]]; then
|
||||
echo "Building..."
|
||||
cmake -S "${SCRIPT_DIR}" -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build "${BUILD_DIR}" -j"$(nproc)"
|
||||
fi
|
||||
|
||||
# Install CLAP plugin
|
||||
CLAP_DIR="${HOME}/.clap"
|
||||
mkdir -p "${CLAP_DIR}"
|
||||
install -m 755 "${BUILD_DIR}/es5-clap.clap" "${CLAP_DIR}/"
|
||||
echo "Installed plugin to ${CLAP_DIR}/es5-clap.clap"
|
||||
|
||||
# Install daemon binary
|
||||
install -Dm 755 "${BUILD_DIR}/es5d" "${HOME}/.local/bin/es5d"
|
||||
echo "Installed daemon to ${HOME}/.local/bin/es5d"
|
||||
|
||||
# Install and enable systemd user service
|
||||
SYSTEMD_DIR="${HOME}/.config/systemd/user"
|
||||
mkdir -p "${SYSTEMD_DIR}"
|
||||
install -m 644 "${SCRIPT_DIR}/dist/es5d.service" "${SYSTEMD_DIR}/"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now es5d.service
|
||||
echo "Enabled and started es5d.service"
|
||||
|
||||
echo "Done. Check status with: systemctl --user status es5d"
|
||||
+1
-1
@@ -17,7 +17,7 @@ static const clap_plugin_descriptor_t s_desc = {
|
||||
.description = "Encoder for Expert Sleepers ES-5 + ESX-8CV/ESX-8GT expanders. "
|
||||
"Generates encoded stereo audio for the ES-9 ADAT output.",
|
||||
.features = (const char *[]){
|
||||
CLAP_PLUGIN_FEATURE_AUDIO_EFFECT,
|
||||
CLAP_PLUGIN_FEATURE_INSTRUMENT,
|
||||
CLAP_PLUGIN_FEATURE_UTILITY,
|
||||
CLAP_PLUGIN_FEATURE_STEREO,
|
||||
nullptr
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Shared memory layout between CLAP plugin and ES-5 daemon
|
||||
// Copyright (c) 2026 Sub-Net e.U. — MIT License
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <atomic>
|
||||
|
||||
#define ES5_SHM_NAME "/es5_state"
|
||||
#define ES5_NUM_HEADERS 6
|
||||
#define ES5_CHANNELS_PER_HEADER 8
|
||||
|
||||
struct ES5SharedState {
|
||||
// Header types: 0=off, 1=GT, 2=CV
|
||||
std::atomic<uint8_t> header_types[ES5_NUM_HEADERS];
|
||||
|
||||
// Gate/CV values per header per channel
|
||||
// GT: >0.5 = on, CV: 0.0–1.0 maps to full DAC range
|
||||
std::atomic<float> values[ES5_NUM_HEADERS][ES5_CHANNELS_PER_HEADER];
|
||||
|
||||
// Flag: plugin is active
|
||||
std::atomic<uint8_t> active;
|
||||
};
|
||||
+53
-66
@@ -5,6 +5,10 @@
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
ES5Plugin::ES5Plugin(const clap_plugin_descriptor *desc, const clap_host *host)
|
||||
: Plugin(desc, host)
|
||||
@@ -12,12 +16,32 @@ ES5Plugin::ES5Plugin(const clap_plugin_descriptor *desc, const clap_host *host)
|
||||
headerTypes_.fill(0.0);
|
||||
for (auto &v : values_)
|
||||
v.fill(0.0);
|
||||
|
||||
// Open shared memory for daemon communication
|
||||
int fd = shm_open(ES5_SHM_NAME, O_CREAT | O_RDWR, 0644);
|
||||
if (fd >= 0) {
|
||||
ftruncate(fd, sizeof(ES5SharedState));
|
||||
shm_ = static_cast<ES5SharedState *>(
|
||||
mmap(nullptr, sizeof(ES5SharedState), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0));
|
||||
close(fd);
|
||||
if (shm_ == MAP_FAILED)
|
||||
shm_ = nullptr;
|
||||
else
|
||||
shm_->active.store(1, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
ES5Plugin::~ES5Plugin() {
|
||||
if (shm_) {
|
||||
shm_->active.store(0, std::memory_order_relaxed);
|
||||
munmap(shm_, sizeof(ES5SharedState));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Audio Ports ---
|
||||
|
||||
uint32_t ES5Plugin::audioPortsCount(bool isInput) const noexcept {
|
||||
return isInput ? 0 : 1; // No input, 1 stereo output
|
||||
return isInput ? 0 : 1;
|
||||
}
|
||||
|
||||
bool ES5Plugin::audioPortsInfo(uint32_t index, bool isInput,
|
||||
@@ -34,11 +58,10 @@ bool ES5Plugin::audioPortsInfo(uint32_t index, bool isInput,
|
||||
|
||||
// --- Parameters ---
|
||||
|
||||
// Build a flat index → (paramId, header, channel) mapping
|
||||
struct ParamEntry {
|
||||
clap_id id;
|
||||
int header; // 0–5, or -1 for N/A
|
||||
int channel; // 0–7, or -1 for header type params
|
||||
int header;
|
||||
int channel;
|
||||
};
|
||||
|
||||
static ParamEntry paramEntryForIndex(uint32_t index) {
|
||||
@@ -63,7 +86,6 @@ bool ES5Plugin::paramsInfo(uint32_t paramIndex, clap_param_info *info) const noe
|
||||
info->cookie = nullptr;
|
||||
|
||||
if (entry.channel < 0) {
|
||||
// Header type parameter
|
||||
info->flags = CLAP_PARAM_IS_AUTOMATABLE | CLAP_PARAM_IS_STEPPED;
|
||||
snprintf(info->name, sizeof(info->name), "Header %d Type", entry.header + 1);
|
||||
snprintf(info->module, sizeof(info->module), "Header %d", entry.header + 1);
|
||||
@@ -71,7 +93,6 @@ bool ES5Plugin::paramsInfo(uint32_t paramIndex, clap_param_info *info) const noe
|
||||
info->max_value = 2.0;
|
||||
info->default_value = 0.0;
|
||||
} else {
|
||||
// Value parameter (gate or CV depending on header type)
|
||||
info->flags = CLAP_PARAM_IS_AUTOMATABLE;
|
||||
snprintf(info->name, sizeof(info->name), "H%d Ch%d", entry.header + 1, entry.channel + 1);
|
||||
snprintf(info->module, sizeof(info->module), "Header %d", entry.header + 1);
|
||||
@@ -84,12 +105,10 @@ bool ES5Plugin::paramsInfo(uint32_t paramIndex, clap_param_info *info) const noe
|
||||
}
|
||||
|
||||
bool ES5Plugin::paramsValue(clap_id paramId, double *value) noexcept {
|
||||
// Header type params: IDs 1–6
|
||||
if (paramId >= 1 && paramId <= 6) {
|
||||
*value = headerTypes_[paramId - 1];
|
||||
return true;
|
||||
}
|
||||
// Value params: IDs x01–x08 where x = 1–6
|
||||
uint32_t h = (paramId / 100) - 1;
|
||||
uint32_t c = (paramId % 100) - 1;
|
||||
if (h < NUM_HEADERS && c < CHANNELS_PER_HEADER) {
|
||||
@@ -137,12 +156,12 @@ void ES5Plugin::paramsFlush(const clap_input_events *in,
|
||||
const clap_output_events *) noexcept {
|
||||
for (uint32_t i = 0; i < in->size(in); ++i)
|
||||
handleEvent(in->get(in, i));
|
||||
syncToSharedMemory();
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
bool ES5Plugin::stateSave(const clap_ostream *stream) noexcept {
|
||||
// Simple binary format: header types then all values
|
||||
for (uint32_t h = 0; h < NUM_HEADERS; ++h) {
|
||||
double v = headerTypes_[h];
|
||||
if (stream->write(stream, &v, sizeof(v)) != sizeof(v)) return false;
|
||||
@@ -169,9 +188,7 @@ bool ES5Plugin::stateLoad(const clap_istream *stream) noexcept {
|
||||
values_[h][c] = v;
|
||||
}
|
||||
}
|
||||
// Reset encoder state on preset load
|
||||
for (auto &enc : cvEncoders_)
|
||||
enc.reset();
|
||||
syncToSharedMemory();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -184,12 +201,10 @@ void ES5Plugin::handleEvent(const clap_event_header *hdr) {
|
||||
auto ev = reinterpret_cast<const clap_event_param_value *>(hdr);
|
||||
clap_id id = ev->param_id;
|
||||
|
||||
// Header type
|
||||
if (id >= 1 && id <= 6) {
|
||||
headerTypes_[id - 1] = ev->value;
|
||||
return;
|
||||
}
|
||||
// Value
|
||||
uint32_t h = (id / 100) - 1;
|
||||
uint32_t c = (id % 100) - 1;
|
||||
if (h < NUM_HEADERS && c < CHANNELS_PER_HEADER) {
|
||||
@@ -197,64 +212,36 @@ void ES5Plugin::handleEvent(const clap_event_header *hdr) {
|
||||
}
|
||||
}
|
||||
|
||||
void ES5Plugin::syncToSharedMemory() {
|
||||
if (!shm_) return;
|
||||
for (uint32_t h = 0; h < NUM_HEADERS; ++h) {
|
||||
shm_->header_types[h].store(
|
||||
static_cast<uint8_t>(headerTypes_[h] + 0.5),
|
||||
std::memory_order_relaxed);
|
||||
for (uint32_t c = 0; c < CHANNELS_PER_HEADER; ++c) {
|
||||
shm_->values[h][c].store(
|
||||
static_cast<float>(values_[h][c]),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clap_process_status ES5Plugin::process(const clap_process *process) noexcept {
|
||||
const uint32_t nframes = process->frames_count;
|
||||
const uint32_t nev = process->in_events->size(process->in_events);
|
||||
uint32_t ev_index = 0;
|
||||
uint32_t next_ev_frame = (nev > 0) ? 0 : nframes;
|
||||
|
||||
// Process parameter events
|
||||
for (uint32_t i = 0; i < nev; ++i)
|
||||
handleEvent(process->in_events->get(process->in_events, i));
|
||||
|
||||
// Sync state to shared memory for the daemon
|
||||
syncToSharedMemory();
|
||||
|
||||
// Output silence — the daemon handles actual audio output via PipeWire
|
||||
float *outL = process->audio_outputs[0].data32[0];
|
||||
float *outR = process->audio_outputs[0].data32[1];
|
||||
|
||||
for (uint32_t i = 0; i < nframes;) {
|
||||
// Process events at this sample
|
||||
while (ev_index < nev && next_ev_frame == i) {
|
||||
auto hdr = process->in_events->get(process->in_events, ev_index);
|
||||
if (hdr->time != i) {
|
||||
next_ev_frame = hdr->time;
|
||||
break;
|
||||
}
|
||||
handleEvent(hdr);
|
||||
++ev_index;
|
||||
if (ev_index == nev) {
|
||||
next_ev_frame = nframes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate encoded audio
|
||||
for (; i < next_ev_frame; ++i) {
|
||||
uint8_t headers[6] = {};
|
||||
|
||||
for (uint32_t h = 0; h < NUM_HEADERS; ++h) {
|
||||
auto type = static_cast<HeaderType>(
|
||||
static_cast<int>(headerTypes_[h] + 0.5));
|
||||
|
||||
switch (type) {
|
||||
case HeaderType::GT: {
|
||||
bool gates[8];
|
||||
for (int c = 0; c < 8; ++c)
|
||||
gates[c] = values_[h][c] > 0.5;
|
||||
headers[h] = gtEncoders_[h].encode(gates);
|
||||
break;
|
||||
}
|
||||
case HeaderType::CV: {
|
||||
// Map 0.0–1.0 → -2048..+2047
|
||||
double cvs[8];
|
||||
for (int c = 0; c < 8; ++c)
|
||||
cvs[c] = values_[h][c] * 4095.0 - 2048.0;
|
||||
headers[h] = cvEncoders_[h].encode(cvs);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
headers[h] = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
es5Encoder_.encode(headers, outL[i], outR[i]);
|
||||
}
|
||||
}
|
||||
memset(outL, 0, nframes * sizeof(float));
|
||||
memset(outR, 0, nframes * sizeof(float));
|
||||
|
||||
return CLAP_PROCESS_CONTINUE;
|
||||
}
|
||||
|
||||
+20
-6
@@ -9,6 +9,7 @@
|
||||
#include <clap/helpers/host-proxy.hxx>
|
||||
#include <array>
|
||||
#include "encoders.h"
|
||||
#include "es5_shared.h"
|
||||
|
||||
// Header type enum (matches parameter stepped values)
|
||||
enum class HeaderType : int { Off = 0, GT = 1, CV = 2 };
|
||||
@@ -35,6 +36,7 @@ class ES5Plugin : public clap::helpers::Plugin<
|
||||
{
|
||||
public:
|
||||
ES5Plugin(const clap_plugin_descriptor *desc, const clap_host *host);
|
||||
~ES5Plugin();
|
||||
|
||||
// --- Audio Ports ---
|
||||
bool implementsAudioPorts() const noexcept override { return true; }
|
||||
@@ -42,6 +44,19 @@ public:
|
||||
bool audioPortsInfo(uint32_t index, bool isInput,
|
||||
clap_audio_port_info *info) const noexcept override;
|
||||
|
||||
// --- Note Ports (required for Bitwig to treat as instrument) ---
|
||||
bool implementsNotePorts() const noexcept override { return true; }
|
||||
uint32_t notePortsCount(bool isInput) const noexcept override { return isInput ? 1 : 0; }
|
||||
bool notePortsInfo(uint32_t index, bool isInput,
|
||||
clap_note_port_info *info) const noexcept override {
|
||||
if (!isInput || index != 0) return false;
|
||||
info->id = 0;
|
||||
info->supported_dialects = CLAP_NOTE_DIALECT_MIDI;
|
||||
info->preferred_dialect = CLAP_NOTE_DIALECT_MIDI;
|
||||
snprintf(info->name, sizeof(info->name), "MIDI In");
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Parameters ---
|
||||
bool implementsParams() const noexcept override { return true; }
|
||||
uint32_t paramsCount() const noexcept override;
|
||||
@@ -64,13 +79,12 @@ public:
|
||||
|
||||
private:
|
||||
void handleEvent(const clap_event_header *hdr);
|
||||
void syncToSharedMemory();
|
||||
|
||||
// Parameter storage
|
||||
std::array<double, NUM_HEADERS> headerTypes_{}; // 0=off, 1=gt, 2=cv
|
||||
std::array<std::array<double, CHANNELS_PER_HEADER>, NUM_HEADERS> values_{}; // 0.0–1.0
|
||||
std::array<double, NUM_HEADERS> headerTypes_{};
|
||||
std::array<std::array<double, CHANNELS_PER_HEADER>, NUM_HEADERS> values_{};
|
||||
|
||||
// Encoder instances
|
||||
es5::ES5Encoder es5Encoder_;
|
||||
std::array<es5::ESX8GTEncoder, NUM_HEADERS> gtEncoders_;
|
||||
std::array<es5::ESX8CVEncoder, NUM_HEADERS> cvEncoders_;
|
||||
// Shared memory for daemon communication
|
||||
ES5SharedState *shm_ = nullptr;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// ES-5 PipeWire test — outputs encoded gate+CV pattern via PipeWire
|
||||
// Tests if PipeWire introduces corruption (like Bitwig does)
|
||||
// Usage: es5_pw_test
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <csignal>
|
||||
#include <algorithm>
|
||||
|
||||
#include <pipewire/pipewire.h>
|
||||
#include <spa/param/audio/format-utils.h>
|
||||
|
||||
static volatile bool running = true;
|
||||
static void sighandler(int) { running = false; }
|
||||
|
||||
// --- ES-5 encoder (from Expert Sleepers MIT source) ---
|
||||
static float bitsToFloat(int32_t bits) {
|
||||
if (bits & 0x800000)
|
||||
return static_cast<float>(0xFFFFFF & (-bits)) / -8388608.0f;
|
||||
else
|
||||
return static_cast<float>(bits) / 8388608.0f;
|
||||
}
|
||||
|
||||
struct ESX8CVEncoder {
|
||||
int phase = 0;
|
||||
uint32_t value = 0;
|
||||
uint8_t encode(const double values[8]) {
|
||||
int p = phase & ~1;
|
||||
int state = (p >> 1) & 3;
|
||||
int dac = (p >> 3) & 7;
|
||||
if (state == 0)
|
||||
value = 2048 + static_cast<int32_t>(std::max(-2048.0, std::min(2047.0, values[dac])));
|
||||
phase += 2;
|
||||
if ((phase & 7) == 6) phase += 2;
|
||||
phase &= 63;
|
||||
uint32_t out;
|
||||
if (state == 0) out = 0x80 | (value & 0x1F);
|
||||
else if (state == 1) out = (value >> 5) & 0x1F;
|
||||
else out = ((dac > 3) ? 0x40 : 0x20) | (value >> 10) | ((dac & 3) << 2);
|
||||
return static_cast<uint8_t>(out);
|
||||
}
|
||||
};
|
||||
|
||||
struct AppData {
|
||||
pw_main_loop *loop;
|
||||
pw_stream *stream;
|
||||
uint32_t sample_count;
|
||||
uint32_t total_samples;
|
||||
bool gates_on;
|
||||
ESX8CVEncoder cv_encoder;
|
||||
};
|
||||
|
||||
static void on_process(void *userdata) {
|
||||
auto *app = static_cast<AppData *>(userdata);
|
||||
|
||||
pw_buffer *b = pw_stream_dequeue_buffer(app->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) {
|
||||
if (++app->sample_count >= 48000) {
|
||||
app->sample_count = 0;
|
||||
app->gates_on = !app->gates_on;
|
||||
fprintf(stderr, " Header 1 gates: %s\n", app->gates_on ? "ALL ON " : "ALL OFF");
|
||||
}
|
||||
app->total_samples++;
|
||||
|
||||
uint8_t h1 = app->gates_on ? 0xFF : 0x00;
|
||||
|
||||
double cvs[8];
|
||||
for (int c = 0; c < 8; ++c) {
|
||||
double t = (app->total_samples + c * 48000) / (double)(48000 * 8);
|
||||
cvs[c] = sin(t * 3.14159265) * 2047.0;
|
||||
}
|
||||
uint8_t h6 = app->cv_encoder.encode(cvs);
|
||||
|
||||
int32_t bitsL = h1 << 16;
|
||||
int32_t bitsR = h6; // headers 4,5 = 0, header 6 = cv byte
|
||||
|
||||
dst[f * 2 + 0] = bitsToFloat(bitsL);
|
||||
dst[f * 2 + 1] = bitsToFloat(bitsR);
|
||||
}
|
||||
|
||||
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(app->stream, b);
|
||||
}
|
||||
|
||||
static const pw_stream_events stream_events = {
|
||||
.version = PW_VERSION_STREAM_EVENTS,
|
||||
.process = on_process,
|
||||
};
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
setbuf(stderr, nullptr);
|
||||
signal(SIGINT, sighandler);
|
||||
|
||||
pw_init(&argc, &argv);
|
||||
|
||||
AppData app{};
|
||||
app.loop = pw_main_loop_new(nullptr);
|
||||
|
||||
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-test",
|
||||
PW_KEY_NODE_DESCRIPTION, "ES-5 Encoder Test",
|
||||
// Target the ES-9 multichannel output
|
||||
PW_KEY_TARGET_OBJECT, "alsa_output.usb-Expert_Sleepers_Ltd_ES-9-01.multichannel-output",
|
||||
PW_KEY_NODE_WANT_DRIVER, "true",
|
||||
nullptr
|
||||
);
|
||||
|
||||
app.stream = pw_stream_new_simple(
|
||||
pw_main_loop_get_loop(app.loop),
|
||||
"es5-test",
|
||||
props,
|
||||
&stream_events,
|
||||
&app
|
||||
);
|
||||
|
||||
// Set up format: stereo F32, 48kHz
|
||||
// Channel positions: AUX12, AUX13 (ES-5 L/R on the ES-9)
|
||||
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(
|
||||
app.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, "ES-5 PipeWire test: targeting ES-9 AUX12/AUX13\n");
|
||||
fprintf(stderr, "Header 1: GT (toggling), Header 6: CV (sweep)\n");
|
||||
fprintf(stderr, "Headers 2-5: must stay OFF. Ctrl+C to stop.\n\n");
|
||||
|
||||
pw_main_loop_run(app.loop);
|
||||
|
||||
pw_stream_destroy(app.stream);
|
||||
pw_main_loop_destroy(app.loop);
|
||||
pw_deinit();
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Direct ES-5 test — outputs encoded gate pattern via ALSA, bypassing Bitwig
|
||||
// Tests header 1 (GT, all gates toggling) + header 6 (CV encoder running)
|
||||
// Headers 2-5 should remain silent — any activity = encoding leak
|
||||
// Usage: es5_test [alsa_device] [channel_offset]
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <csignal>
|
||||
#include <algorithm>
|
||||
#include <alsa/asoundlib.h>
|
||||
|
||||
static volatile bool running = true;
|
||||
static void sighandler(int) { running = false; }
|
||||
|
||||
// --- ESX-8CV encoder (from Expert Sleepers MIT-licensed source) ---
|
||||
struct ESX8CVEncoder {
|
||||
int phase = 0;
|
||||
uint32_t value = 0;
|
||||
|
||||
uint8_t encode(const double values[8]) {
|
||||
int p = phase & ~1;
|
||||
int state = (p >> 1) & 3;
|
||||
int dac = (p >> 3) & 7;
|
||||
|
||||
if (state == 0) {
|
||||
double s = values[dac];
|
||||
value = 2048 + static_cast<int32_t>(
|
||||
std::max(-2048.0, std::min(2047.0, s)));
|
||||
}
|
||||
|
||||
phase += 2;
|
||||
if ((phase & 7) == 6)
|
||||
phase += 2;
|
||||
phase &= 63;
|
||||
|
||||
uint32_t out;
|
||||
if (state == 0)
|
||||
out = 0x80 | (value & 0x1F);
|
||||
else if (state == 1)
|
||||
out = (value >> 5) & 0x1F;
|
||||
else
|
||||
out = ((dac > 3) ? 0x40 : 0x20) | (value >> 10) | ((dac & 3) << 2);
|
||||
|
||||
return static_cast<uint8_t>(out);
|
||||
}
|
||||
};
|
||||
|
||||
// Pack 24-bit integer into 3 bytes (little-endian)
|
||||
static void pack_s24_3le(uint8_t *dst, int32_t val) {
|
||||
uint32_t u = static_cast<uint32_t>(val) & 0xFFFFFF;
|
||||
dst[0] = u & 0xFF;
|
||||
dst[1] = (u >> 8) & 0xFF;
|
||||
dst[2] = (u >> 16) & 0xFF;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
setbuf(stdout, nullptr);
|
||||
|
||||
const char *device = argc > 1 ? argv[1] : "hw:7";
|
||||
int ch_offset = argc > 2 ? atoi(argv[2]) : 12;
|
||||
const unsigned rate = 48000;
|
||||
const unsigned channels = 16;
|
||||
|
||||
signal(SIGINT, sighandler);
|
||||
|
||||
snd_pcm_t *pcm = nullptr;
|
||||
int err = snd_pcm_open(&pcm, device, SND_PCM_STREAM_PLAYBACK, 0);
|
||||
if (err < 0) {
|
||||
fprintf(stderr, "Cannot open %s: %s\n", device, snd_strerror(err));
|
||||
return 1;
|
||||
}
|
||||
|
||||
snd_pcm_hw_params_t *hw;
|
||||
snd_pcm_hw_params_alloca(&hw);
|
||||
snd_pcm_hw_params_any(pcm, hw);
|
||||
snd_pcm_hw_params_set_access(pcm, hw, SND_PCM_ACCESS_RW_INTERLEAVED);
|
||||
snd_pcm_hw_params_set_format(pcm, hw, SND_PCM_FORMAT_S24_3LE);
|
||||
snd_pcm_hw_params_set_channels(pcm, hw, channels);
|
||||
unsigned actual_rate = rate;
|
||||
snd_pcm_hw_params_set_rate_near(pcm, hw, &actual_rate, 0);
|
||||
snd_pcm_uframes_t period = 1024;
|
||||
snd_pcm_hw_params_set_period_size_near(pcm, hw, &period, 0);
|
||||
|
||||
if ((err = snd_pcm_hw_params(pcm, hw)) < 0) {
|
||||
fprintf(stderr, "Cannot set hw params: %s\n", snd_strerror(err));
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("ES-5 test: device=%s rate=%u channels=%u ES-5_L/R=ch%d/%d\n",
|
||||
device, actual_rate, channels, ch_offset, ch_offset + 1);
|
||||
printf("Header 1: GT (toggling all gates every second)\n");
|
||||
printf("Header 6: CV (sweeping all 8 CVs)\n");
|
||||
printf("Headers 2-5: should be OFF (any activity = leak!)\n");
|
||||
printf("Ctrl+C to stop.\n\n");
|
||||
|
||||
int buf_frame_bytes = channels * 3;
|
||||
uint8_t *buf = new uint8_t[period * buf_frame_bytes]();
|
||||
uint32_t sample_count = 0;
|
||||
uint32_t total_samples = 0;
|
||||
bool gates_on = false;
|
||||
|
||||
ESX8CVEncoder cv_encoder;
|
||||
|
||||
while (running) {
|
||||
for (unsigned f = 0; f < period; ++f) {
|
||||
if (++sample_count >= rate) {
|
||||
sample_count = 0;
|
||||
gates_on = !gates_on;
|
||||
printf(" Header 1 gates: %s | CV sweep: %.0f%%\n",
|
||||
gates_on ? "ALL ON " : "ALL OFF",
|
||||
fmod(total_samples / (double)rate, 8.0) / 8.0 * 100.0);
|
||||
}
|
||||
total_samples++;
|
||||
|
||||
// Header 1: GT
|
||||
uint8_t h1 = gates_on ? 0xFF : 0x00;
|
||||
|
||||
// Header 6: CV — sweep each channel slowly
|
||||
double cvs[8];
|
||||
for (int c = 0; c < 8; ++c) {
|
||||
double t = (total_samples + c * rate) / (double)(rate * 8);
|
||||
cvs[c] = sin(t * 3.14159265) * 2047.0; // ±2047 sweep
|
||||
}
|
||||
uint8_t h6 = cv_encoder.encode(cvs);
|
||||
|
||||
// Headers 2-5: OFF (must stay 0x00)
|
||||
uint8_t h2 = 0, h3 = 0, h4 = 0, h5 = 0;
|
||||
|
||||
int32_t bitsL = (h1 << 16) | (h2 << 8) | h3;
|
||||
int32_t bitsR = (h4 << 16) | (h5 << 8) | h6;
|
||||
|
||||
uint8_t *frame = &buf[f * buf_frame_bytes];
|
||||
memset(frame, 0, buf_frame_bytes);
|
||||
pack_s24_3le(&frame[ch_offset * 3], bitsL);
|
||||
pack_s24_3le(&frame[(ch_offset + 1) * 3], bitsR);
|
||||
}
|
||||
|
||||
snd_pcm_sframes_t written = snd_pcm_writei(pcm, buf, period);
|
||||
if (written < 0) {
|
||||
fprintf(stderr, " Write error: %s, recovering...\n", snd_strerror(written));
|
||||
snd_pcm_recover(pcm, written, 0);
|
||||
}
|
||||
}
|
||||
|
||||
delete[] buf;
|
||||
snd_pcm_close(pcm);
|
||||
printf("\nDone.\n");
|
||||
return 0;
|
||||
}
|
||||
+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