1
0

Notify web UI of assignments, and listeners of changes from UI

This commit is contained in:
Geraint 2025-06-26 00:07:02 +01:00
parent 105e00a1ef
commit 97efa774aa
3 changed files with 290 additions and 171 deletions

View File

@ -152,6 +152,16 @@ struct Plugin : public clap_plugin {
this->get_extension = plugin_get_extension;
this->on_main_thread = plugin_on_main_thread;
effect.paramListenerRange = [&](stfx::web::ParamContext context, double v){
std::cout << "param #" << context.index << " change: " << v << "\n";
};
effect.paramListenerGesture = [&](stfx::web::ParamContext context, bool gesture){
std::cout << "param #" << context.index << " gesture: " << gesture << "\n";
};
effect.paramListenerStepped = [&](stfx::web::ParamContext context, int v){
std::cout << "param #" << context.index << " change: " << v << "\n";
};
scanParams();
}
@ -308,7 +318,9 @@ struct Plugin : public clap_plugin {
}
inputBuffers.resize(0);
outputBuffers.resize(0);
plugin.host->request_callback(plugin.host);
if (plugin.effect.hasPendingWebMessage()) {
plugin.host->request_callback(plugin.host);
}
@ -368,6 +380,7 @@ struct Plugin : public clap_plugin {
}
template<class PR>
RangeParamInfo & range(const char *key, PR &param) {
param.context.index = params.size();
params.emplace_back();
auto &entry = params.back();
entry.id = crc.copy().addString(key, true).done();
@ -376,6 +389,7 @@ struct Plugin : public clap_plugin {
}
template<class PS>
SteppedParamInfo & stepped(const char *key, PS &param) {
param.context.index = params.size();
params.emplace_back();
auto &entry = params.back();
entry.id = crc.copy().addString(key, true).done();
@ -616,8 +630,7 @@ struct Plugin : public clap_plugin {
if (!fillFromStream(stream, buffer)) return false;
StateReader storage{buffer};
plugin.effect.state(storage);
plugin.effect.markStateDirty();
plugin.sendWebMessages(); // should already be on the main thread
return true;
}

View File

@ -39,6 +39,11 @@
grid-area: header;
display: flex;
justify-content: center;
margin: 0.5rem;
font-weight: normal;
letter-spacing: 0.1ex;
transform: scale(0.95, 1);
}
#params {
grid-area: params;
@ -77,6 +82,7 @@
top: 30%;
left: 0;
width: 100%;
letter-spacing: -0.1ex;
}
.param-range-units {
position: absolute;
@ -99,14 +105,24 @@
<label class="param-range" @if="${d => d.$type == 'ParamRange'}">
<div class="param-range-name">{name}</div>
<script>
function move(data, dx, dy) {
data.rangeUnit = Math.min(1, Math.max(0, data.rangeUnit - dy/250));
function move(data, dx, dy, element) {
let prev = data.gesture ? data._gestureUnit : data.rangeUnit;
data._gestureUnit = data.rangeUnit = Math.min(1, Math.max(0, prev - dy/250));
}
function press(data, count) {
if (count == 2) data.value = data.defaultValue;
data.gesture = true;
data._gestureUnit = data.rangeUnit;
if (count == 2) {
console.log("data.gesture", data.gesture);
data.value = data.defaultValue;
data.gesture = false;
}
}
function unpress(data) {
data.gesture = false;
}
</script>
<div class="param-range-value" $move="${move}" $press="${press}">
<div class="param-range-value" $move="${move}" $press="${press}" $unpress="${unpress}">
<canvas class="param-range-dial" $update="${drawDial}"></canvas>
<output class="param-range-text">{text}</output><br>
<div class="param-range-units">{textUnits}</div>
@ -129,15 +145,23 @@
context.translate(1, 1);
context.beginPath();
context.ellipse(0, 0, 1, 1, 0, 0, Math.PI*2);
context.ellipse(0, 0, 0.8, 0.8, 0, 0, Math.PI*2);
context.fillStyle = '#FFF';
context.fill();
context.beginPath();
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*(-1.249 + data.rangeUnit*1.499));
context.strokeStyle = '#000';
context.lineWidth = 0.2;
context.lineCap = 'round';
context.beginPath();
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*0.25);
context.strokeStyle = '#0001';
context.stroke();
let rangeUnit = data.rangeUnit;
if (data.gesture) rangeUnit = data._gestureUnit;
context.beginPath();
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*(-1.249 + rangeUnit*1.499));
context.strokeStyle = '#000';
context.stroke();
}
</script>

View File

@ -12,6 +12,101 @@
namespace stfx { namespace web {
struct ParamContext {
union {
void *pointer;
size_t index;
};
ParamContext() : index(0) {}
};
struct WebStateWriter : public storage::StorageCborWriter<false, WebStateWriter> {
using Super = storage::StorageCborWriter<false, WebStateWriter>;
using Super::Super;
void info(const char *name, const char *desc) {
extra("name", name);
extra("desc", desc);
}
int version(int v) {
return v; // we don't use old states, so not worth tracking
}
bool extra() {
return true;
}
template<class V>
void extra(const char *key, V v) {
(*this)(key, v);
}
template<class PR>
RangeParamIgnore range(const char *key, PR &param) {
(*this)(key, param);
return {};
}
template<class PS>
SteppedParamIgnore stepped(const char *key, PS &param) {
(*this)(key, param);
return {};
}
template<class V>
bool changed(const char *key, V &v) {
(*this)(key, v);
return false;
}
void invalidate(const char *) {}
};
struct WebStateReader : public storage::StorageCborReader<false, WebStateReader> {
using Super = storage::StorageCborReader<false, WebStateReader>;
using Super::Super;
void info(const char *, const char *) {}
int version(int v) {return v;}
bool extra() {
// We're interested in an extended set of values being sent back, even if we ignore actual "extra" things
return true;
}
template<class V>
void extra(const char *key, V v) {}
template<class PR>
RangeParamIgnore range(const char *key, PR &param) {
(*this)(key, param);
return {};
}
template<class PS>
SteppedParamIgnore stepped(const char *key, PS &param) {
(*this)(key, param);
return {};
}
template<class V>
bool changed(const char *key, V &v) {
V prev = v;
(*this)(key, v);
return v != prev;
}
void invalidate(const char *invalidatedKey) {
LOG_EXPR(invalidatedKey);
}
};
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
@ -25,74 +120,86 @@ The WebBase template replaces the ParamRange/ParamStepped classes, so that the U
template<template<class, class...> class EffectSTFX>
struct WebUIHelper {
struct MaybeChanged {
virtual void maybeChanged() = 0;
};
struct WebParamScanner {
MaybeChanged *effect;
// Do nothing for most types
template<class V>
void operator()(const char *key, V &v) {
if constexpr (std::is_void_v<decltype(v.state(*this))>) {
v.state(*this);
}
}
// 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.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.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 *) {}
};
template<class Effect>
template<class Effect, class SubClass>
class WebBase : public Effect {
using SuperRange = typename Effect::ParamRange;
using SuperStepped = typename Effect::ParamStepped;
public:
using Effect::Effect;
protected:
struct WebParamScanner {
SubClass *effect;
std::vector<std::string> scope;
WebParamScanner(SubClass *effect) : effect(effect) {}
// Do nothing for most types
template<class V>
void operator()(const char *key, V &v) {
scope.emplace_back(key);
// TODO: detect objects with .state() somehow
v.state(*this);
scope.pop_back();
}
// 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) {
SuperRange::operator=(v);
effect->maybeChanged();
sendUpdateMessage();
return *this;
}
template<class Storage>
void state(Storage &storage) {
double prevV = *this;
bool prevGesture = gesture;
SuperRange::state(storage);
if (storage.extra()) {
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);
@ -100,34 +207,64 @@ struct WebUIHelper {
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)) {
*this = info->fromUnit(unit);
SuperRange::operator=(info->fromUnit(unit));
storage.invalidate("value");
}
}
if (prevV != *this) {
effect->maybeChanged();
storage.invalidate("rangeUnit");
storage.invalidate("text");
storage.invalidate("textUnits");
if (isWeb) {
if (effect->paramListenerRange) {
effect->paramListenerRange(context, *this);
}
effect->requestEntireState(); // 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;
MaybeChanged *effect = nullptr;
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;
ParamStepped & operator=(double v) {
ParamContext context;
ParamStepped & operator=(int v) {
SuperStepped::operator=(v);
effect->maybeChanged();
sendUpdateMessage();
return *this;
}
@ -136,119 +273,68 @@ struct WebUIHelper {
int prevV = *this;
SuperStepped::state(storage);
if (storage.extra()) {
storage.extra("name", info->name);
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) {
effect->maybeChanged();
storage.invalidate("text");
if (isWeb) {
if (effect->paramListenerStepped) {
effect->paramListenerStepped(context, *this);
}
effect->requestEntireState(); // TODO: shouldn't be necessary once we have `.invalidate()` working
} else {
sendUpdateMessage();
}
}
}
private:
friend struct WebParamScanner;
MaybeChanged *effect = nullptr;
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();
}
}
};
};
struct WebStateWriter : public storage::StorageCborWriter<false, WebStateWriter> {
using Super = storage::StorageCborWriter<false, WebStateWriter>;
using Super::Super;
void info(const char *name, const char *desc) {
extra("name", name);
extra("desc", desc);
}
int version(int v) {
return v; // we don't use old states, so not worth tracking
}
bool extra() {
return true;
}
template<class V>
void extra(const char *key, V v) {
(*this)(key, v);
}
template<class PR>
RangeParamIgnore range(const char *key, PR &param) {
(*this)(key, param);
return {};
}
template<class PS>
SteppedParamIgnore stepped(const char *key, PS &param) {
(*this)(key, param);
return {};
}
template<class V>
bool changed(const char *key, V &v) {
(*this)(key, v);
return false;
}
void invalidate(const char *) {}
};
struct WebStateReader : public storage::StorageCborReader<false, WebStateReader> {
using Super = storage::StorageCborReader<false, WebStateReader>;
using Super::Super;
void info(const char *, const char *) {}
int version(int v) {return v;}
bool extra() {
// We're interested in an extended set of values being sent back, even if we ignore actual "extra" things
return true;
}
template<class V>
void extra(const char *key, V v) {}
template<class PR>
RangeParamIgnore range(const char *key, PR &param) {
(*this)(key, param);
return {};
}
template<class PS>
SteppedParamIgnore stepped(const char *key, PS &param) {
(*this)(key, param);
return {};
}
template<class V>
bool changed(const char *key, V &v) {
V prev = v;
(*this)(key, v);
return v != prev;
}
void invalidate(const char *invalidatedKey) {
LOG_EXPR(invalidatedKey);
}
};
template<class Effect, class... ExtraArgs>
struct WebSTFX : public EffectSTFX<WebBase<Effect>, ExtraArgs...>, MaybeChanged {
// Inherit constructor(s)
using Super = EffectSTFX<WebBase<Effect>, ExtraArgs...>;
struct WebSTFX : public EffectSTFX<WebBase<Effect, WebSTFX<Effect, ExtraArgs...>>, ExtraArgs...> {
/* TODO: without the ExtraArgs, it would be a bit neater:
EffectSTFX<WebBase<Effect, WebSTFX<Effect>>>
*/
using Super = EffectSTFX<WebBase<Effect, WebSTFX<Effect, ExtraArgs...>>, ExtraArgs...>;
template<class... Args>
WebSTFX(Args... args) : Super(args...) {
WebParamScanner scanner{this};
typename Super::WebParamScanner scanner{this};
this->state(scanner);
};
std::string webPage = "html/generic.html";
int webWidth = 640, webHeight = 480;
// 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;
struct WebMessage {
std::vector<unsigned char> bytes;
@ -259,15 +345,18 @@ struct WebUIHelper {
void sent() {
readyToSend.clear();
}
private:
friend struct WebSTFX;
friend class WebSTFX;
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();
@ -313,18 +402,11 @@ struct WebUIHelper {
this->state(reader);
}
}
void maybeChanged() override {
std::cout << "web effect maybe changed\n";
// TODO: something better
requestEntireState();
}
void markStateDirty() {
requestEntireState();
}
private:
friend struct Super::ParamRange;
friend struct Super::ParamStepped;
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;