From 6a117a9ba9665ea4bfa4237bfc13758a121c7f19 Mon Sep 17 00:00:00 2001 From: sub Date: Sat, 21 Mar 2026 01:43:32 +0100 Subject: [PATCH] Initial CLAP plugin scaffold for ES-5 encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 10 ++ CMakeLists.txt | 59 +++++++++ README.md | 62 ++++++++- src/encoders.h | 95 ++++++++++++++ src/entry.cpp | 69 ++++++++++ src/linux-es5-clap.version | 6 + src/plugin.cpp | 260 +++++++++++++++++++++++++++++++++++++ src/plugin.h | 76 +++++++++++ 8 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 src/encoders.h create mode 100644 src/entry.cpp create mode 100644 src/linux-es5-clap.version create mode 100644 src/plugin.cpp create mode 100644 src/plugin.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b05c70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +build/ +cmake-build-*/ +.cache/ +compile_commands.json +*.clap +*.so +*.dylib +*.dll +.idea/ +.vscode/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..bb237f1 --- /dev/null +++ b/CMakeLists.txt @@ -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() diff --git a/README.md b/README.md index 9003b40..1e265ed 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file +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 | 1–6 | Off / GT / CV | Expander type on each header | +| HN Ch1–8 | N01–N08 | 0.0–1.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 1–3, R = headers 4–6). 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. diff --git a/src/encoders.h b/src/encoders.h new file mode 100644 index 0000000..15d7fe5 --- /dev/null +++ b/src/encoders.h @@ -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 +#include +#include + +namespace es5 { + +// --- Bit ↔ Float conversion (from es5encoder~/main.cpp) --- + +inline float bitsToFloat(int32_t bits) { + if (bits & 0x800000) + return static_cast(0xFFFFFF & (-bits)) / -8388608.0f; + else + return static_cast(bits) / 8388608.0f; +} + +// --- ES-5 Encoder: packs 6 × byte (0–255) 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 (0–255) 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( + 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(out); + } +}; + +} // namespace es5 diff --git a/src/entry.cpp b/src/entry.cpp new file mode 100644 index 0000000..56c324e --- /dev/null +++ b/src/entry.cpp @@ -0,0 +1,69 @@ +// ES-5 CLAP Plugin entry point +// Copyright (c) 2026 Sub-Net e.U. — MIT License + +#include +#include +#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, +}; +} diff --git a/src/linux-es5-clap.version b/src/linux-es5-clap.version new file mode 100644 index 0000000..bf1a243 --- /dev/null +++ b/src/linux-es5-clap.version @@ -0,0 +1,6 @@ +{ + global: + clap_entry; + local: + *; +}; diff --git a/src/plugin.cpp b/src/plugin.cpp new file mode 100644 index 0000000..59154e6 --- /dev/null +++ b/src/plugin.cpp @@ -0,0 +1,260 @@ +// ES-5 CLAP Plugin implementation +// Copyright (c) 2026 Sub-Net e.U. — MIT License + +#include "plugin.h" +#include +#include +#include + +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; // 0–5, or -1 for N/A + int channel; // 0–7, or -1 for header type params +}; + +static ParamEntry paramEntryForIndex(uint32_t index) { + if (index < NUM_HEADERS) { + return {headerTypeParamId(index), static_cast(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(h), static_cast(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 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) { + *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(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(static_cast(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(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( + 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]); + } + } + + return CLAP_PROCESS_CONTINUE; +} diff --git a/src/plugin.h b/src/plugin.h new file mode 100644 index 0000000..6f7459e --- /dev/null +++ b/src/plugin.h @@ -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 +#include +#include +#include +#include +#include "encoders.h" + +// Header type enum (matches parameter stepped values) +enum class HeaderType : int { Off = 0, GT = 1, CV = 2 }; + +// Parameter ID scheme: +// 1–6: Header type (Off/GT/CV) +// 101–108: Header 1 values +// 201–208: Header 2 values +// ... +// 601–608: 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 headerTypes_{}; // 0=off, 1=gt, 2=cv + std::array, NUM_HEADERS> values_{}; // 0.0–1.0 + + // Encoder instances + es5::ES5Encoder es5Encoder_; + std::array gtEncoders_; + std::array cvEncoders_; +};