1
0
basics/stfx/ui/web-ui.h
2025-07-01 15:05:33 +01:00

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 &param) {
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 &param) {
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