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
+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;
}