diff --git a/CMakeLists.txt b/CMakeLists.txt index bb237f1..ddc2273 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/dist/es5d.service b/dist/es5d.service new file mode 100644 index 0000000..02a46a3 --- /dev/null +++ b/dist/es5d.service @@ -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 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..184fb4c --- /dev/null +++ b/install.sh @@ -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" diff --git a/src/entry.cpp b/src/entry.cpp index 56c324e..7c5fab4 100644 --- a/src/entry.cpp +++ b/src/entry.cpp @@ -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 diff --git a/src/es5_shared.h b/src/es5_shared.h new file mode 100644 index 0000000..0b43468 --- /dev/null +++ b/src/es5_shared.h @@ -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 +#include + +#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 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 values[ES5_NUM_HEADERS][ES5_CHANNELS_PER_HEADER]; + + // Flag: plugin is active + std::atomic active; +}; diff --git a/src/plugin.cpp b/src/plugin.cpp index 59154e6..bc0e731 100644 --- a/src/plugin.cpp +++ b/src/plugin.cpp @@ -5,6 +5,10 @@ #include #include #include +#include +#include +#include +#include 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( + 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(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(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(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( - static_cast(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; } diff --git a/src/plugin.h b/src/plugin.h index 6f7459e..a563871 100644 --- a/src/plugin.h +++ b/src/plugin.h @@ -9,6 +9,7 @@ #include #include #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 headerTypes_{}; // 0=off, 1=gt, 2=cv - std::array, NUM_HEADERS> values_{}; // 0.0–1.0 + std::array headerTypes_{}; + std::array, NUM_HEADERS> values_{}; - // Encoder instances - es5::ES5Encoder es5Encoder_; - std::array gtEncoders_; - std::array cvEncoders_; + // Shared memory for daemon communication + ES5SharedState *shm_ = nullptr; }; diff --git a/tools/es5_pw_test.cpp b/tools/es5_pw_test.cpp new file mode 100644 index 0000000..68d0423 --- /dev/null +++ b/tools/es5_pw_test.cpp @@ -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 +#include +#include +#include +#include +#include + +#include +#include + +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(0xFFFFFF & (-bits)) / -8388608.0f; + else + return static_cast(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(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(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(userdata); + + pw_buffer *b = pw_stream_dequeue_buffer(app->stream); + if (!b) return; + + spa_buffer *buf = b->buffer; + float *dst = static_cast(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_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; +} diff --git a/tools/es5_test.cpp b/tools/es5_test.cpp new file mode 100644 index 0000000..b8a59d7 --- /dev/null +++ b/tools/es5_test.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +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( + 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(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(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; +} diff --git a/tools/es5d.cpp b/tools/es5d.cpp new file mode 100644 index 0000000..91bd93a --- /dev/null +++ b/tools/es5d.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#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(userdata); + auto *shm = app->shm; + + pw_buffer *b = pw_stream_dequeue_buffer(static_cast( + // 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(userdata); + + pw_buffer *b = pw_stream_dequeue_buffer(d->stream); + if (!b) return; + + spa_buffer *buf = b->buffer; + float *dst = static_cast(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( + 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_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; +}