Initial CLAP plugin scaffold for ES-5 encoder

Implements the full encoding pipeline for Expert Sleepers ES-5 with
ESX-8GT (gate) and ESX-8CV (CV) expanders as a native CLAP plugin.

Encoder logic ported from Expert Sleepers' MIT-licensed Pure Data
externals (https://github.com/expertsleepersltd/externals).

- ES-5 encoder: packs 6 × 8-bit header values into stereo 24-bit audio
- ESX-8GT encoder: 8 boolean gates → 1 byte
- ESX-8CV encoder: 8 × 12-bit CV values via 64-phase state machine
- 54 automatable parameters (6 header types + 48 gate/CV values)
- State save/load for preset support
- Builds on Linux (tested), macOS, and Windows
This commit is contained in:
sub
2026-03-21 01:43:32 +01:00
parent 977480ca3e
commit 6a117a9ba9
8 changed files with 636 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
build/
cmake-build-*/
.cache/
compile_commands.json
*.clap
*.so
*.dylib
*.dll
.idea/
.vscode/
+59
View File
@@ -0,0 +1,59 @@
cmake_minimum_required(VERSION 3.17)
project(es5-clap VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
include(FetchContent)
FetchContent_Declare(
clap
GIT_REPOSITORY https://github.com/free-audio/clap.git
GIT_TAG 1.2.7
)
FetchContent_MakeAvailable(clap)
FetchContent_Declare(
clap-helpers
GIT_REPOSITORY https://github.com/free-audio/clap-helpers.git
GIT_TAG main
)
FetchContent_MakeAvailable(clap-helpers)
add_library(${PROJECT_NAME} MODULE
src/plugin.cpp
src/entry.cpp
)
target_link_libraries(${PROJECT_NAME} PRIVATE clap-helpers)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
set_target_properties(${PROJECT_NAME} PROPERTIES
PREFIX ""
SUFFIX ".clap"
)
target_link_options(${PROJECT_NAME} PRIVATE
-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/src/linux-es5-clap.version
-Wl,-z,defs
)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
set_target_properties(${PROJECT_NAME} PROPERTIES
BUNDLE TRUE
BUNDLE_EXTENSION clap
MACOSX_BUNDLE_GUI_IDENTIFIER at.sub-net.es5-clap
MACOSX_BUNDLE_BUNDLE_NAME "ES-5 Encoder"
MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}"
MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}"
)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set_target_properties(${PROJECT_NAME} PROPERTIES
PREFIX ""
SUFFIX ".clap"
)
endif()
# Install to standard CLAP paths
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
install(TARGETS ${PROJECT_NAME} LIBRARY DESTINATION lib/clap)
endif()
+61 -1
View File
@@ -1,3 +1,63 @@
# es5-clap
Native CLAP plugin for Expert Sleepers ES-5/ESX-8CV/ESX-8GT encoder — Bitwig-friendly replacement for Silent Way
Native [CLAP](https://cleveraudio.org/) plugin for encoding gate and CV data for the
[Expert Sleepers ES-5](https://www.expert-sleepers.co.uk/es5.html) module via the
[ES-9](https://www.expert-sleepers.co.uk/es9.html) ADAT output.
Replaces Silent Way's ES-5 Controller for DAWs with CLAP support (Bitwig, REAPER, etc.).
## Supported Hardware
- **ES-5 mk3** — 6 expansion headers, encoded via stereo ADAT pair
- **ESX-8GT** — 8 gate outputs per header (accent/velocity via PWM planned)
- **ESX-8CV** — 8 CV outputs per header (12-bit DAC, ±10V range)
## Building
```bash
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)
```
## Installation
Copy the built plugin to your CLAP plugin directory:
```bash
# Linux
cp build/es5-clap.clap ~/.clap/
# macOS
cp -r build/es5-clap.clap ~/Library/Audio/Plug-Ins/CLAP/
```
## Usage in Bitwig
1. Add **ES-5 Encoder** as an audio effect on a track
2. Route the track's audio output to the ES-9 ADAT channel pair feeding your ES-5
(default: ADAT channels 7/8, configurable via jumper on the ES-5 PCB)
3. Set each header's type to match your connected expander (GT or CV)
4. Automate the gate/CV parameters from Bitwig's modulation system
### Parameter Layout
| Parameter | ID | Range | Description |
|---|---|---|---|
| Header N Type | 16 | Off / GT / CV | Expander type on each header |
| HN Ch18 | N01N08 | 0.01.0 | Gate (>0.5 = on) or CV value |
## Encoder Protocol
Based on the [MIT-licensed Pure Data externals](https://github.com/expertsleepersltd/externals)
by Expert Sleepers Ltd.
The ES-5 receives a stereo audio stream where each 24-bit sample packs 3 bytes
(L = headers 13, R = headers 46). Each byte drives one expansion header:
- **ESX-8GT**: each bit = one gate output (8 gates per byte)
- **ESX-8CV**: 12-bit values serialized via a 3-state, 64-phase protocol
## License
MIT — see [LICENSE](LICENSE).
Encoder logic derived from Expert Sleepers Ltd MIT-licensed source code.
+95
View File
@@ -0,0 +1,95 @@
// ES-5 / ESX-8CV / ESX-8GT encoder logic
// Ported from Expert Sleepers MIT-licensed Pure Data externals:
// https://github.com/expertsleepersltd/externals
//
// Copyright (c) 2014 Expert Sleepers Ltd — MIT License
// CLAP adaptation Copyright (c) 2026 Sub-Net e.U. — MIT License
#pragma once
#include <algorithm>
#include <array>
#include <cstdint>
namespace es5 {
// --- Bit ↔ Float conversion (from es5encoder~/main.cpp) ---
inline float bitsToFloat(int32_t bits) {
if (bits & 0x800000)
return static_cast<float>(0xFFFFFF & (-bits)) / -8388608.0f;
else
return static_cast<float>(bits) / 8388608.0f;
}
// --- ES-5 Encoder: packs 6 × byte (0255) into stereo float ---
struct ES5Encoder {
// Pack 6 header byte values into L/R audio samples
void encode(const uint8_t headers[6], float &outL, float &outR) {
int32_t bitsL = (headers[0] << 16) | (headers[1] << 8) | headers[2];
int32_t bitsR = (headers[3] << 16) | (headers[4] << 8) | headers[5];
outL = bitsToFloat(bitsL);
outR = bitsToFloat(bitsR);
}
};
// --- ESX-8GT Encoder: 8 gate booleans → 1 byte ---
struct ESX8GTEncoder {
uint8_t encode(const bool gates[8]) {
uint8_t out = 0;
for (int i = 0; i < 8; ++i) {
if (gates[i])
out |= (1 << i);
}
return out;
}
};
// --- ESX-8CV Encoder: 8 × 12-bit CV values → 1 byte per sample (stateful) ---
// Ported from esx8cvencoder~/main.cpp
// Input CV range: -2048 to +2047 (maps to ≈ ±10V on the hardware)
struct ESX8CVEncoder {
int phase = 0;
uint32_t value = 0;
void reset() {
phase = 0;
value = 0;
}
// values[0..7]: CV inputs in range -2048..+2047
// Returns encoded byte (0255) for this sample
uint8_t encode(const double values[8]) {
// Non-SMUX mode (standard for ES-9 ADAT)
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)));
}
// Advance phase (increment by 2 in non-SMUX mode)
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);
}
};
} // namespace es5
+69
View File
@@ -0,0 +1,69 @@
// ES-5 CLAP Plugin entry point
// Copyright (c) 2026 Sub-Net e.U. — MIT License
#include <clap/clap.h>
#include <cstring>
#include "plugin.h"
static const clap_plugin_descriptor_t s_desc = {
.clap_version = CLAP_VERSION_INIT,
.id = "at.sub-net.es5-encoder",
.name = "ES-5 Encoder",
.vendor = "Sub-Net e.U.",
.url = "https://git.sub-net.at/submodular/es5-clap",
.manual_url = "https://git.sub-net.at/submodular/es5-clap",
.support_url = "https://git.sub-net.at/submodular/es5-clap/issues",
.version = "0.1.0",
.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_UTILITY,
CLAP_PLUGIN_FEATURE_STEREO,
nullptr
},
};
static const clap_plugin_t *create_plugin(
const clap_plugin_factory *,
const clap_host_t *host,
const char *plugin_id)
{
if (!clap_version_is_compatible(host->clap_version))
return nullptr;
if (strcmp(plugin_id, s_desc.id) != 0)
return nullptr;
auto p = new ES5Plugin(&s_desc, host);
return p->clapPlugin();
}
static uint32_t get_plugin_count(const clap_plugin_factory *) { return 1; }
static const clap_plugin_descriptor_t *get_plugin_descriptor(
const clap_plugin_factory *, uint32_t index)
{
return index == 0 ? &s_desc : nullptr;
}
static const clap_plugin_factory_t s_factory = {
.get_plugin_count = get_plugin_count,
.get_plugin_descriptor = get_plugin_descriptor,
.create_plugin = create_plugin,
};
static bool entry_init(const char *) { return true; }
static void entry_deinit() {}
static const void *entry_get_factory(const char *factory_id) {
if (!strcmp(factory_id, CLAP_PLUGIN_FACTORY_ID))
return &s_factory;
return nullptr;
}
extern "C" {
CLAP_EXPORT const clap_plugin_entry_t clap_entry = {
.clap_version = CLAP_VERSION_INIT,
.init = entry_init,
.deinit = entry_deinit,
.get_factory = entry_get_factory,
};
}
+6
View File
@@ -0,0 +1,6 @@
{
global:
clap_entry;
local:
*;
};
+260
View File
@@ -0,0 +1,260 @@
// ES-5 CLAP Plugin implementation
// Copyright (c) 2026 Sub-Net e.U. — MIT License
#include "plugin.h"
#include <cstdio>
#include <cstring>
#include <cmath>
ES5Plugin::ES5Plugin(const clap_plugin_descriptor *desc, const clap_host *host)
: Plugin(desc, host)
{
headerTypes_.fill(0.0);
for (auto &v : values_)
v.fill(0.0);
}
// --- Audio Ports ---
uint32_t ES5Plugin::audioPortsCount(bool isInput) const noexcept {
return isInput ? 0 : 1; // No input, 1 stereo output
}
bool ES5Plugin::audioPortsInfo(uint32_t index, bool isInput,
clap_audio_port_info *info) const noexcept {
if (isInput || index != 0) return false;
info->id = 0;
snprintf(info->name, sizeof(info->name), "ES-5 Output");
info->channel_count = 2;
info->flags = CLAP_AUDIO_PORT_IS_MAIN;
info->port_type = CLAP_PORT_STEREO;
info->in_place_pair = CLAP_INVALID_ID;
return true;
}
// --- Parameters ---
// Build a flat index → (paramId, header, channel) mapping
struct ParamEntry {
clap_id id;
int header; // 05, or -1 for N/A
int channel; // 07, or -1 for header type params
};
static ParamEntry paramEntryForIndex(uint32_t index) {
if (index < NUM_HEADERS) {
return {headerTypeParamId(index), static_cast<int>(index), -1};
}
uint32_t vi = index - NUM_HEADERS;
uint32_t h = vi / CHANNELS_PER_HEADER;
uint32_t c = vi % CHANNELS_PER_HEADER;
return {headerValueParamId(h, c), static_cast<int>(h), static_cast<int>(c)};
}
uint32_t ES5Plugin::paramsCount() const noexcept {
return TOTAL_PARAMS;
}
bool ES5Plugin::paramsInfo(uint32_t paramIndex, clap_param_info *info) const noexcept {
if (paramIndex >= TOTAL_PARAMS) return false;
auto entry = paramEntryForIndex(paramIndex);
info->id = entry.id;
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);
info->min_value = 0.0;
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);
info->min_value = 0.0;
info->max_value = 1.0;
info->default_value = 0.0;
}
return true;
}
bool ES5Plugin::paramsValue(clap_id paramId, double *value) noexcept {
// Header type params: IDs 16
if (paramId >= 1 && paramId <= 6) {
*value = headerTypes_[paramId - 1];
return true;
}
// Value params: IDs x01x08 where x = 16
uint32_t h = (paramId / 100) - 1;
uint32_t c = (paramId % 100) - 1;
if (h < NUM_HEADERS && c < CHANNELS_PER_HEADER) {
*value = values_[h][c];
return true;
}
return false;
}
bool ES5Plugin::paramsValueToText(clap_id paramId, double value,
char *display, uint32_t size) noexcept {
if (paramId >= 1 && paramId <= 6) {
int v = static_cast<int>(value + 0.5);
const char *names[] = {"Off", "GT (Gate)", "CV"};
snprintf(display, size, "%s", names[std::max(0, std::min(2, v))]);
return true;
}
uint32_t h = (paramId / 100) - 1;
if (h < NUM_HEADERS) {
auto type = static_cast<HeaderType>(static_cast<int>(headerTypes_[h] + 0.5));
if (type == HeaderType::GT)
snprintf(display, size, "%s", value > 0.5 ? "ON" : "OFF");
else if (type == HeaderType::CV)
snprintf(display, size, "%.1f%%", value * 100.0);
else
snprintf(display, size, "%.2f", value);
return true;
}
return false;
}
bool ES5Plugin::paramsTextToValue(clap_id paramId, const char *display,
double *value) noexcept {
if (paramId >= 1 && paramId <= 6) {
if (!strcmp(display, "Off") || !strcmp(display, "off")) { *value = 0; return true; }
if (!strcmp(display, "GT") || !strcmp(display, "gt")) { *value = 1; return true; }
if (!strcmp(display, "CV") || !strcmp(display, "cv")) { *value = 2; return true; }
}
*value = atof(display);
return true;
}
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));
}
// --- 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;
}
for (uint32_t h = 0; h < NUM_HEADERS; ++h) {
for (uint32_t c = 0; c < CHANNELS_PER_HEADER; ++c) {
double v = values_[h][c];
if (stream->write(stream, &v, sizeof(v)) != sizeof(v)) return false;
}
}
return true;
}
bool ES5Plugin::stateLoad(const clap_istream *stream) noexcept {
for (uint32_t h = 0; h < NUM_HEADERS; ++h) {
double v;
if (stream->read(stream, &v, sizeof(v)) != sizeof(v)) return false;
headerTypes_[h] = v;
}
for (uint32_t h = 0; h < NUM_HEADERS; ++h) {
for (uint32_t c = 0; c < CHANNELS_PER_HEADER; ++c) {
double v;
if (stream->read(stream, &v, sizeof(v)) != sizeof(v)) return false;
values_[h][c] = v;
}
}
// Reset encoder state on preset load
for (auto &enc : cvEncoders_)
enc.reset();
return true;
}
// --- Process ---
void ES5Plugin::handleEvent(const clap_event_header *hdr) {
if (hdr->space_id != CLAP_CORE_EVENT_SPACE_ID) return;
if (hdr->type != CLAP_EVENT_PARAM_VALUE) return;
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) {
values_[h][c] = ev->value;
}
}
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;
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.01.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]);
}
}
return CLAP_PROCESS_CONTINUE;
}
+76
View File
@@ -0,0 +1,76 @@
// ES-5 CLAP Plugin — Bitwig-native encoder for Expert Sleepers ES-5 + ESX expanders
// Copyright (c) 2026 Sub-Net e.U. — MIT License
#pragma once
#include <clap/helpers/plugin.hh>
#include <clap/helpers/plugin.hxx>
#include <clap/helpers/host-proxy.hh>
#include <clap/helpers/host-proxy.hxx>
#include <array>
#include "encoders.h"
// Header type enum (matches parameter stepped values)
enum class HeaderType : int { Off = 0, GT = 1, CV = 2 };
// Parameter ID scheme:
// 16: Header type (Off/GT/CV)
// 101108: Header 1 values
// 201208: Header 2 values
// ...
// 601608: Header 6 values
constexpr uint32_t NUM_HEADERS = 6;
constexpr uint32_t CHANNELS_PER_HEADER = 8;
constexpr uint32_t TOTAL_PARAMS = NUM_HEADERS + NUM_HEADERS * CHANNELS_PER_HEADER; // 54
constexpr clap_id headerTypeParamId(uint32_t header) { return header + 1; }
constexpr clap_id headerValueParamId(uint32_t header, uint32_t ch) {
return (header + 1) * 100 + ch + 1;
}
class ES5Plugin : public clap::helpers::Plugin<
clap::helpers::MisbehaviourHandler::Terminate,
clap::helpers::CheckingLevel::Maximal>
{
public:
ES5Plugin(const clap_plugin_descriptor *desc, const clap_host *host);
// --- Audio Ports ---
bool implementsAudioPorts() const noexcept override { return true; }
uint32_t audioPortsCount(bool isInput) const noexcept override;
bool audioPortsInfo(uint32_t index, bool isInput,
clap_audio_port_info *info) const noexcept override;
// --- Parameters ---
bool implementsParams() const noexcept override { return true; }
uint32_t paramsCount() const noexcept override;
bool paramsInfo(uint32_t paramIndex, clap_param_info *info) const noexcept override;
bool paramsValue(clap_id paramId, double *value) noexcept override;
bool paramsValueToText(clap_id paramId, double value,
char *display, uint32_t size) noexcept override;
bool paramsTextToValue(clap_id paramId, const char *display,
double *value) noexcept override;
void paramsFlush(const clap_input_events *in,
const clap_output_events *out) noexcept override;
// --- State ---
bool implementsState() const noexcept override { return true; }
bool stateSave(const clap_ostream *stream) noexcept override;
bool stateLoad(const clap_istream *stream) noexcept override;
// --- Process ---
clap_process_status process(const clap_process *process) noexcept override;
private:
void handleEvent(const clap_event_header *hdr);
// 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.01.0
// Encoder instances
es5::ES5Encoder es5Encoder_;
std::array<es5::ESX8GTEncoder, NUM_HEADERS> gtEncoders_;
std::array<es5::ESX8CVEncoder, NUM_HEADERS> cvEncoders_;
};