459 lines
13 KiB
C++
459 lines
13 KiB
C++
#pragma once
|
|
|
|
#include "../storage/cbor-walker.h"
|
|
#include "../storage/stfx-storage.h"
|
|
#include "../param-info.h"
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
#include <functional>
|
|
#include <atomic>
|
|
#include <memory>
|
|
#include <iostream> // we log to stderr if our queue gets full
|
|
|
|
namespace stfx { namespace web {
|
|
|
|
struct ParamContext {
|
|
union {
|
|
void *pointer;
|
|
size_t index;
|
|
};
|
|
|
|
ParamContext() : index(0) {}
|
|
};
|
|
|
|
struct WebStateWriter : public storage::STFXStorageWriter<WebStateWriter> {
|
|
using Super = storage::STFXStorageWriter<WebStateWriter>;
|
|
|
|
using Super::Super;
|
|
|
|
// There should never be a version mismatch, ignore this
|
|
int version(int v) {return v;}
|
|
|
|
// Include extra info
|
|
bool extra() {
|
|
return true;
|
|
}
|
|
template<class V>
|
|
void extra(const char *key, V v) {
|
|
(*this)(key, v);
|
|
}
|
|
};
|
|
|
|
struct WebStateReader : public storage::STFXStorageReader<WebStateReader> {
|
|
using Super = storage::STFXStorageReader<WebStateReader>;
|
|
|
|
using Super::Super;
|
|
|
|
// There should never be a version mismatch, ignore this
|
|
int version(int v) {return v;}
|
|
|
|
// We're interested in an extended set of values being sent back
|
|
bool extra() {
|
|
return true;
|
|
}
|
|
// But anything read-only gets skipped
|
|
template<class V>
|
|
void extra(const char *key, V v) {}
|
|
|
|
void invalidate(const char *invalidatedKey) {
|
|
// TODO: right now this is hacked together, by the params re-sending themselves whenever they would otherwise invalidate.
|
|
// Instead, this would let us reply with only the keys which changed
|
|
}
|
|
};
|
|
|
|
template<class S>
|
|
constexpr bool storageIsWeb() {return false;}
|
|
template<>
|
|
constexpr bool storageIsWeb<WebStateWriter>() {return true;}
|
|
template<>
|
|
constexpr bool storageIsWeb<WebStateReader>() {return true;}
|
|
|
|
/* Given an STFX template which (when insantiated) has inheritance like:
|
|
|
|
EffectSTFX : Effect
|
|
|
|
This produces a template with inheritance like:
|
|
|
|
WebSTFX : EffectSTFX : WebBase : Effect
|
|
|
|
The WebBase template replaces the ParamRange/ParamStepped classes, so that the UI can be updated when they change. The outer WebSTFX class adds methods for forwarding messages between the effect and its webview.
|
|
*/
|
|
|
|
template<template<class, class...> class EffectSTFX, class SubClass>
|
|
struct WebUIHelper {
|
|
|
|
template<class Effect>
|
|
class WebBase : public Effect {
|
|
using SuperRange = typename Effect::ParamRange;
|
|
using SuperStepped = typename Effect::ParamStepped;
|
|
|
|
protected:
|
|
struct WebParamScanner : public storage::STFXStorageScanner<WebParamScanner>{
|
|
SubClass *effect;
|
|
std::vector<std::string> scope;
|
|
|
|
WebParamScanner(SubClass *effect) : effect(effect) {}
|
|
|
|
// Extra methods we add for STFX storage
|
|
void info(const char *, const char *) {}
|
|
int version(int v) {
|
|
return v;
|
|
}
|
|
template<class PR>
|
|
RangeParamInfo & range(const char *key, PR ¶m) {
|
|
param.effect = effect;
|
|
param.scope = scope;
|
|
param.scope.emplace_back(key);
|
|
param.info = std::unique_ptr<RangeParamInfo>{
|
|
new RangeParamInfo(param)
|
|
};
|
|
return *param.info;
|
|
}
|
|
template<class PS>
|
|
SteppedParamInfo & stepped(const char *key, PS ¶m) {
|
|
param.effect = effect;
|
|
param.scope = scope;
|
|
param.scope.emplace_back(key);
|
|
param.info = std::unique_ptr<SteppedParamInfo>{
|
|
new SteppedParamInfo(param)
|
|
};
|
|
return *param.info;
|
|
}
|
|
template<class V>
|
|
bool changed(const char *key, V &v) {
|
|
(*this)(key, v);
|
|
return false;
|
|
}
|
|
void invalidate(const char *) {}
|
|
};
|
|
|
|
public:
|
|
using Effect::Effect;
|
|
|
|
std::string webPage = "html/generic.html";
|
|
int webWidth = 640, webHeight = 480;
|
|
|
|
struct ParamRange : public SuperRange {
|
|
using SuperRange::SuperRange;
|
|
|
|
ParamContext context;
|
|
|
|
ParamRange & operator=(double v) {
|
|
bool changed = (v != *this);
|
|
SuperRange::operator=(v);
|
|
if (changed) sendUpdateMessage();
|
|
return *this;
|
|
}
|
|
|
|
template<class Storage>
|
|
void state(Storage &storage) {
|
|
double prevV = *this;
|
|
bool prevGesture = gesture;
|
|
SuperRange::state(storage);
|
|
|
|
constexpr bool isWeb = storageIsWeb<Storage>();
|
|
if (isWeb) { // a bunch of extra state
|
|
storage.extra("$type", "ParamRange");
|
|
storage.extra("name", info->name);
|
|
storage.extra("defaultValue", info->defaultValue);
|
|
std::string text, units;
|
|
info->toString((double)*this, text, units);
|
|
storage.extra("text", text);
|
|
storage.extra("textUnits", units);
|
|
storage("gesture", gesture);
|
|
|
|
double unit = info->toUnit(*this);
|
|
if (storage.changed("rangeUnit", unit)) {
|
|
SuperRange::operator=(info->fromUnit(unit));
|
|
storage.invalidate("value");
|
|
}
|
|
}
|
|
|
|
if (prevV != *this) {
|
|
storage.invalidate("rangeUnit");
|
|
storage.invalidate("text");
|
|
storage.invalidate("textUnits");
|
|
if (isWeb) {
|
|
if (effect->paramListenerRange) {
|
|
effect->paramListenerRange(context, *this);
|
|
}
|
|
sendUpdateMessage(); // TODO: shouldn't be necessary once we have `.invalidate()` working
|
|
} else {
|
|
sendUpdateMessage();
|
|
}
|
|
}
|
|
if (gesture != prevGesture) {
|
|
if (isWeb && effect->paramListenerGesture) {
|
|
effect->paramListenerGesture(context, gesture);
|
|
}
|
|
}
|
|
}
|
|
|
|
private:
|
|
friend struct WebParamScanner;
|
|
SubClass *effect = nullptr;
|
|
std::vector<std::string> scope;
|
|
std::unique_ptr<RangeParamInfo> info;
|
|
bool gesture = false;
|
|
|
|
void sendUpdateMessage() {
|
|
if (auto *m = effect->getEmptyMessage()) {
|
|
signalsmith::cbor::CborWriter cbor{m->bytes};
|
|
for (auto &key : scope) {
|
|
cbor.openMap(1);
|
|
cbor.addUtf8(key);
|
|
}
|
|
WebStateWriter storage{cbor};
|
|
state(storage);
|
|
m->markReady();
|
|
}
|
|
}
|
|
};
|
|
|
|
struct ParamStepped : public SuperStepped {
|
|
using SuperStepped::SuperStepped;
|
|
|
|
ParamContext context;
|
|
|
|
ParamStepped & operator=(int v) {
|
|
bool changed = (v != *this);
|
|
SuperStepped::operator=(v);
|
|
if (changed) sendUpdateMessage();
|
|
return *this;
|
|
}
|
|
|
|
template<class Storage>
|
|
void state(Storage &storage) {
|
|
int prevV = *this;
|
|
SuperStepped::state(storage);
|
|
|
|
constexpr bool isWeb = storageIsWeb<Storage>();
|
|
if (isWeb) {
|
|
storage.extra("$type", "ParamStepped");
|
|
storage.extra("low", info->low);
|
|
storage.extra("high", info->high);
|
|
storage.extra("name", info->name);
|
|
std::string text = info->toString((int)*this);
|
|
storage.extra("text", text);
|
|
}
|
|
|
|
if (prevV != *this) {
|
|
storage.invalidate("text");
|
|
if (isWeb) {
|
|
if (effect->paramListenerStepped) {
|
|
effect->paramListenerStepped(context, *this);
|
|
}
|
|
sendUpdateMessage(); // TODO: shouldn't be necessary once we have `.invalidate()` working
|
|
} else {
|
|
sendUpdateMessage();
|
|
}
|
|
}
|
|
}
|
|
|
|
private:
|
|
friend struct WebParamScanner;
|
|
SubClass *effect = nullptr;
|
|
std::vector<std::string> scope;
|
|
std::unique_ptr<SteppedParamInfo> info;
|
|
|
|
void sendUpdateMessage() {
|
|
if (auto *m = effect->getEmptyMessage()) {
|
|
signalsmith::cbor::CborWriter cbor{m->bytes};
|
|
for (auto &key : scope) {
|
|
cbor.openMap(1);
|
|
cbor.addUtf8(key);
|
|
}
|
|
WebStateWriter storage{cbor};
|
|
state(storage);
|
|
m->markReady();
|
|
}
|
|
}
|
|
};
|
|
|
|
template<class Storage>
|
|
void meterState(Storage &storage) {}
|
|
};
|
|
|
|
template<class Effect, class... ExtraArgs>
|
|
struct WebSTFX : public EffectSTFX<WebBase<Effect>, ExtraArgs...> {
|
|
using Super = EffectSTFX<WebBase<Effect>, ExtraArgs...>;
|
|
};
|
|
};
|
|
|
|
// Use this instead of a plain LibraryEffect
|
|
template<typename Sample, template<class, class...> class EffectSTFX, class... ExtraArgs>
|
|
struct WebUILibraryEffect : public LibraryEffect<Sample, WebUIHelper<EffectSTFX, WebUILibraryEffect<Sample, EffectSTFX, ExtraArgs...>>::template WebSTFX, ExtraArgs...> {
|
|
using Super = LibraryEffect<Sample, WebUIHelper<EffectSTFX, WebUILibraryEffect<Sample, EffectSTFX, ExtraArgs...>>::template WebSTFX, ExtraArgs...>;
|
|
|
|
template<class... Args>
|
|
WebUILibraryEffect(Args... args) : Super(args...) {
|
|
typename Super::WebParamScanner scanner{this};
|
|
this->state(scanner);
|
|
};
|
|
|
|
// These are called when the parameter is changed from the web UI
|
|
std::function<void(ParamContext, double)> paramListenerRange;
|
|
std::function<void(ParamContext, bool)> paramListenerGesture;
|
|
std::function<void(ParamContext, int)> paramListenerStepped;
|
|
|
|
// This state includes all the parameters
|
|
void saveState(std::vector<unsigned char> &bytes) {
|
|
stfx::storage::STFXStorageWriter<> storage(bytes);
|
|
this->state(storage);
|
|
}
|
|
void loadState(const std::vector<unsigned char> &bytes) {
|
|
stfx::storage::STFXStorageReader<> storage(bytes);
|
|
this->state(storage);
|
|
requestEntireState();
|
|
}
|
|
|
|
struct WebMessage {
|
|
std::vector<unsigned char> bytes;
|
|
|
|
WebMessage() {
|
|
bytes.reserve(256);
|
|
}
|
|
|
|
void sent() {
|
|
readyToSend.clear();
|
|
}
|
|
|
|
private:
|
|
friend class WebUILibraryEffect;
|
|
friend struct Super::ParamRange;
|
|
friend struct Super::ParamStepped;
|
|
std::atomic_flag readyToSend = ATOMIC_FLAG_INIT;
|
|
|
|
void markReady() {
|
|
readyToSend.test_and_set();
|
|
}
|
|
};
|
|
|
|
bool hasPendingWebMessage() const {
|
|
auto &message = queue[readIndex];
|
|
return message.readyToSend.test();
|
|
}
|
|
|
|
// Poll on the main thread (and directly after any `.webReceive()`), calling `.sent()` after you've sent it, repeat until you get `nullptr`
|
|
WebMessage * getPendingWebMessage() {
|
|
auto &message = queue[readIndex];
|
|
if (!message.readyToSend.test()) return nullptr;
|
|
if (++readIndex == queue.size()) readIndex = 0;
|
|
|
|
if (resetQueue.test()) {
|
|
// Clear ("send") every message aside from this one
|
|
for (auto &m : queue) {
|
|
if (&m == &message) continue;
|
|
m.sent();
|
|
}
|
|
// Ready to start sending messages again, starting from the next index
|
|
writeIndex.store(readIndex);
|
|
resetQueue.clear();
|
|
// any messages (state updates) sent after this won't assume a *newer* state than what we serialise below
|
|
|
|
// Replace this one's message with the complete plugin state
|
|
WebStateWriter storage(message.bytes);
|
|
this->state(storage);
|
|
}
|
|
return &message;
|
|
}
|
|
|
|
// Call when the webview posts any message back
|
|
void webReceive(const void *message, size_t size) {
|
|
auto *bytes = (const unsigned char *)message;
|
|
signalsmith::cbor::TaggedCborWalker cbor(bytes, bytes + size);
|
|
|
|
if (cbor.isUtf8()) {
|
|
if (cbor.utf8View() == "ready") {
|
|
requestEntireState();
|
|
return;
|
|
}
|
|
} else if (cbor.isArray()) {
|
|
size_t length = cbor.length();
|
|
cbor = cbor.enter();
|
|
if (cbor.utf8View() == "meters" && length == 3) {
|
|
double interval = ++cbor;
|
|
double duration = ++cbor;
|
|
metersInterval.store(interval*this->config.sampleRate);
|
|
metersDuration.store(duration*this->config.sampleRate);
|
|
}
|
|
} else if (cbor.isMap()) {
|
|
// Apply it as a merge
|
|
WebStateReader reader(cbor);
|
|
this->state(reader);
|
|
}
|
|
}
|
|
|
|
void webClosed() {
|
|
// stops new messages from being queued up until the reset message is sent
|
|
requestEntireState();
|
|
}
|
|
|
|
// Replace `.process()` to add meter messages if they exist
|
|
template<class Buffers>
|
|
void process(Buffers &&buffers, int blockLength) {
|
|
process(buffers, buffers, blockLength);
|
|
}
|
|
|
|
template<class Inputs, class Outputs>
|
|
void process(Inputs &&inputs, Outputs &&outputs, int blockLength) {
|
|
Super::process(inputs, outputs, blockLength);
|
|
|
|
if (this->hasMeters()) {
|
|
auto *m = getEmptyMessage();
|
|
if (m) {
|
|
signalsmith::cbor::CborWriter cbor{m->bytes};
|
|
WebStateWriter storage{cbor};
|
|
this->meterState(storage);
|
|
m->markReady();
|
|
}
|
|
this->wantsMeters(false);
|
|
}
|
|
|
|
if (metersDuration.load() > 0) {
|
|
metersDuration -= blockLength;
|
|
samplesSinceMeters += blockLength;
|
|
if (samplesSinceMeters > metersInterval.load()) {
|
|
this->wantsMeters();
|
|
samplesSinceMeters = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
private:
|
|
friend struct Super::ParamRange;
|
|
friend struct Super::ParamStepped;
|
|
|
|
std::atomic<int> metersInterval = 0, metersDuration = 0;
|
|
int samplesSinceMeters = 0; // only used from `.process()`
|
|
|
|
std::vector<WebMessage> queue = std::vector<WebMessage>(64); // power of 2 so that overflow doesn't mess with the modulo
|
|
size_t readIndex = 0;
|
|
std::atomic_flag resetQueue = ATOMIC_FLAG_INIT;
|
|
|
|
// Atomic because multiple threads might write
|
|
std::atomic<size_t> writeIndex = 0;
|
|
WebMessage * getEmptyMessage() {
|
|
if (resetQueue.test()) return nullptr;
|
|
auto reservedIndex = writeIndex.fetch_add(1);
|
|
auto &message = queue[reservedIndex%queue.size()];
|
|
if (message.readyToSend.test()) {
|
|
std::cerr << "Web message queue full - sending entire state instead\n";
|
|
// When our queue is full, we drop the entire thing and re-send the entire state
|
|
resetQueue.test_and_set();
|
|
return nullptr;
|
|
}
|
|
message.bytes.resize(0);
|
|
return &message;
|
|
}
|
|
|
|
void requestEntireState() {
|
|
if (auto *m = getEmptyMessage()) {
|
|
resetQueue.test_and_set();
|
|
m->markReady();
|
|
} // if this fails, then the queue is full and we're doing a reset anyway
|
|
}};
|
|
|
|
}} // namespace
|